Merge branch 'main' into joins

This commit is contained in:
rijkvanzanten
2020-10-26 16:57:23 +01:00
82 changed files with 1174 additions and 296 deletions

2
api/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"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.",
@@ -106,6 +106,7 @@
"liquidjs": "^9.14.1",
"lodash": "^4.17.19",
"macos-release": "^2.4.1",
"mime-types": "^2.1.27",
"ms": "^2.1.2",
"nanoid": "^3.1.12",
"node-machine-id": "^1.1.12",

View File

@@ -60,7 +60,6 @@ const newFieldSchema = Joi.object({
field: Joi.string().required(),
type: Joi.string().valid(...types, null),
schema: Joi.object({
comment: Joi.string().allow(null),
default_value: Joi.any(),
max_length: [Joi.number(), Joi.string(), Joi.valid(null)],
is_nullable: Joi.bool(),

View File

@@ -42,6 +42,8 @@ columns:
column: id
modified_on:
type: timestamp
nullable: false
default: '$now'
charset:
type: string
length: 50

View File

@@ -135,15 +135,18 @@ fields:
- field: key
name: Key
type: string
schema:
is_nullable: false
meta:
interface: slug
options:
onlyOnCreate: false
required: true
width: half
- field: fit
name: Fit
type: string
schema:
is_nullable: false
meta:
interface: dropdown
options:
@@ -152,34 +155,35 @@ fields:
text: Contain (preserve aspect ratio)
- value: cover
text: Cover (forces exact size)
required: true
width: half
- field: width
name: Width
type: integer
schema:
is_nullable: false
meta:
interface: numeric
required: true
width: half
- field: height
name: Height
type: integer
schema:
is_nullable: false
meta:
interface: numeric
required: true
width: half
- field: quality
type: integer
name: Quality
schema:
default_value: 80
is_nullable: false
meta:
interface: slider
options:
max: 100
min: 0
step: 1
required: true
width: full
template: '{{key}}'
special: json

View File

@@ -99,8 +99,9 @@ async function createTables(database: Knex) {
if (columnInfo.default !== undefined) {
let defaultValue = columnInfo.default;
if (isObject(defaultValue) || Array.isArray(defaultValue))
if (isObject(defaultValue) || Array.isArray(defaultValue)) {
defaultValue = JSON.stringify(defaultValue);
}
if (defaultValue === '$now') {
defaultValue = database!.fn.now();

View File

@@ -72,7 +72,7 @@ function registerHooks(hooks: string[]) {
registerHook(hook);
} catch (error) {
logger.warn(`Couldn't register hook "${hook}"`);
logger.info(error);
logger.warn(error);
}
}
@@ -104,7 +104,7 @@ function registerEndpoints(endpoints: string[], router: Router) {
registerEndpoint(endpoint);
} catch (error) {
logger.warn(`Couldn't register endpoint "${endpoint}"`);
logger.info(error);
logger.warn(error);
}
}

View File

@@ -17,22 +17,25 @@ import { ForbiddenException, FailedValidationException } from '../exceptions';
import { uniq, merge, flatten } from 'lodash';
import generateJoi from '../utils/generate-joi';
import { ItemsService } from './items';
import { PayloadService } from './payload';
import { parseFilter } from '../utils/parse-filter';
import { toArray } from '../utils/to-array';
export class AuthorizationService {
knex: Knex;
accountability: Accountability | null;
payloadService: PayloadService;
constructor(options?: AbstractServiceOptions) {
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.payloadService = new PayloadService('directus_permissions', { knex: this.knex });
}
async processAST(ast: AST, action: PermissionsAction = 'read'): Promise<AST> {
const collectionsRequested = getCollectionsFromAST(ast);
const permissionsForCollections = await this.knex
let permissionsForCollections = await this.knex
.select<Permission[]>('*')
.from('directus_permissions')
.where({ action, role: this.accountability?.role })
@@ -41,6 +44,11 @@ export class AuthorizationService {
collectionsRequested.map(({ collection }) => collection)
);
permissionsForCollections = (await this.payloadService.processValues(
'read',
permissionsForCollections
)) as Permission[];
// If the permissions don't match the collections, you don't have permission to read all of them
const uniqueCollectionsRequestedCount = uniq(
collectionsRequested.map(({ collection }) => collection)
@@ -111,7 +119,7 @@ export class AuthorizationService {
(permission) => permission.collection === collection
)!;
const allowedFields = permissions.fields?.split(',') || [];
const allowedFields = permissions.fields || [];
for (const childNode of ast.children) {
if (childNode.type !== 'field') {
@@ -213,21 +221,26 @@ export class AuthorizationService {
permissions: {},
validation: {},
limit: null,
fields: '*',
fields: ['*'],
presets: {},
};
} else {
permission = await this.knex
.select<Permission>('*')
.select('*')
.from('directus_permissions')
.where({ action, collection, role: this.accountability?.role || null })
.first();
permission = (await this.payloadService.processValues(
'read',
permission as Item
)) as Permission;
// Check if you have permission to access the fields you're trying to acces
if (!permission) throw new ForbiddenException();
const allowedFields = permission.fields?.split(',') || [];
const allowedFields = permission.fields || [];
if (allowedFields.includes('*') === false) {
for (const payload of payloads) {
@@ -260,13 +273,16 @@ export class AuthorizationService {
.from('directus_fields')
.where({ collection, field: column.name })
.first();
const specials = (field?.special || '').split(',');
const hasGenerateSpecial = [
'uuid',
'date-created',
'role-created',
'user-created',
].some((name) => specials.includes(name));
const isRequired =
column.is_nullable === false &&
column.has_auto_increment === false &&

View File

@@ -260,7 +260,13 @@ export class FieldsService {
}
if (field.schema.default_value) {
column.defaultTo(field.schema.default_value);
const defaultValue = field.schema.default_value.toLowerCase();
if (defaultValue === 'now()') {
column.defaultTo(this.knex.fn.now());
} else {
column.defaultTo(field.schema.default_value);
}
}
if (field.schema.is_nullable !== undefined && field.schema.is_nullable === false) {

View File

@@ -4,12 +4,13 @@ import sharp from 'sharp';
import { parse as parseICC } from 'icc';
import parseEXIF from 'exif-reader';
import parseIPTC from '../utils/parse-iptc';
import path from 'path';
import { AbstractServiceOptions, File, PrimaryKey } from '../types';
import { clone } from 'lodash';
import cache from '../cache';
import { ForbiddenException } from '../exceptions';
import { toArray } from '../utils/to-array';
import { extension } from 'mime-types';
import path from 'path';
export class FilesService extends ItemsService {
constructor(options?: AbstractServiceOptions) {
@@ -37,7 +38,10 @@ export class FilesService extends ItemsService {
primaryKey = await this.create(payload);
}
payload.filename_disk = primaryKey + path.extname(payload.filename_download);
const fileExtension =
(payload.type && extension(payload.type)) || path.extname(payload.filename_download);
payload.filename_disk = primaryKey + fileExtension;
if (!payload.type) {
payload.type = 'application/octet-stream';

View File

@@ -18,7 +18,6 @@ import {
GraphQLInputObjectType,
ObjectFieldNode,
GraphQLID,
ValueNode,
FieldNode,
GraphQLFieldConfigMap,
GraphQLInt,
@@ -26,11 +25,9 @@ import {
StringValueNode,
BooleanValueNode,
ArgumentNode,
GraphQLScalarType,
GraphQLBoolean,
ObjectValueNode,
GraphQLUnionType,
GraphQLUnionTypeConfig,
} from 'graphql';
import { getGraphQLType } from '../utils/get-graphql-type';
import { RelationsService } from './relations';
@@ -65,7 +62,7 @@ export class GraphQLService {
this.knex = options?.knex || database;
this.fieldsService = new FieldsService(options);
this.collectionsService = new CollectionsService(options);
this.relationsService = new RelationsService({ knex: this.knex });
this.relationsService = new RelationsService(options);
}
args = {
@@ -138,6 +135,7 @@ export class GraphQLService {
const relatedIsSystem = relationForField.one_collection!.startsWith(
'directus_'
);
const relatedType = relatedIsSystem
? schema[relationForField.one_collection!.substring(9)].type
: schema.items[relationForField.one_collection!].type;

View File

@@ -25,7 +25,9 @@ export class RelationsService extends ItemsService {
| ParsedRelation
| ParsedRelation[]
| null;
const filteredResults = await this.filterForbidden(results);
return filteredResults;
}
@@ -58,6 +60,7 @@ export class RelationsService extends ItemsService {
this.accountability?.role || null,
'read'
);
const allowedFields = await this.permissionsService.getAllowedFields(
this.accountability?.role || null,
'read'

View File

@@ -26,7 +26,6 @@ export type FieldMeta = {
interface: string | null;
options: Record<string, any> | null;
locked: boolean;
required: boolean;
readonly: boolean;
hidden: boolean;
sort: number | null;

View File

@@ -9,5 +9,5 @@ export type Permission = {
validation: Record<string, any>;
limit: number | null;
presets: Record<string, any> | null;
fields: string | null;
fields: string[] | null;
};

2
app/package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"private": false,
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
"author": "Rijk van Zanten <rijk@rngr.org>",

View File

@@ -10,7 +10,7 @@
<template #activator>{{ field.name || formatTitle(field.field) }}</template>
<field-list-item
v-for="childField in field.children"
:key="childField.field"
:key="field.field + childField.field"
:parent="`${parent ? parent + '.' : ''}${field.field}`"
:field="childField"
:depth="depth - 1"

View File

@@ -263,7 +263,7 @@ export default defineComponent({
if (part.startsWith('{{') === false) {
return `<span class="text">${part}</span>`;
}
const fieldKey = part.replaceAll(/({|})/g, '').trim();
const fieldKey = part.replace(/({|})/g, '').trim();
const field = findTree(tree.value, fieldKey.split('.'));
if (!field) return '';

View File

@@ -88,6 +88,7 @@
<script lang="ts">
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import uploadFiles from '@/utils/upload-files';
import uploadFile from '@/utils/upload-file';
import DrawerCollection from '@/views/private/components/drawer-collection';
import api from '@/api';
import useItem from '@/composables/use-item';
@@ -103,6 +104,10 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
fileId: {
type: String,
default: null,
},
fromUrl: {
type: Boolean,
default: false,
@@ -155,16 +160,29 @@ export default defineComponent({
try {
numberOfFiles.value = files.length;
const uploadedFiles = await uploadFiles(Array.from(files), {
onProgressChange: (percentage) => {
progress.value = Math.round(percentage.reduce((acc, cur) => (acc += cur)) / files.length);
done.value = percentage.filter((p) => p === 100).length;
},
preset: props.preset,
});
if (props.multiple === true) {
const uploadedFiles = await uploadFiles(Array.from(files), {
onProgressChange: (percentage) => {
progress.value = Math.round(
percentage.reduce((acc, cur) => (acc += cur)) / files.length
);
done.value = percentage.filter((p) => p === 100).length;
},
preset: props.preset,
});
if (uploadedFiles) {
emit('input', props.multiple ? uploadedFiles : uploadedFiles[0]);
uploadedFiles && emit('input', uploadedFiles);
} else {
const uploadedFile = await uploadFile(Array.from(files)[0], {
onProgressChange: (percentage) => {
progress.value = percentage;
done.value = percentage === 100 ? 1 : 0;
},
fileId: props.fileId,
preset: props.preset,
});
uploadedFile && emit('input', uploadedFile);
}
} catch (err) {
console.error(err);

View File

@@ -16,6 +16,20 @@ export function useCollection(collectionKey: string | Ref<string>) {
return fieldsStore.getFieldsForCollection(collection.value);
});
const defaults = computed(() => {
if (!fields.value) return {};
const defaults: Record<string, any> = {};
for (const field of fields.value) {
if (field.schema?.default_value) {
defaults[field.field] = field.schema.default_value;
}
}
return defaults;
});
const primaryKeyField = computed(() => {
// Every collection has a primary key; rules of the land
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -32,5 +46,5 @@ export function useCollection(collectionKey: string | Ref<string>) {
return info.value?.meta?.sort_field || null;
});
return { info, fields, primaryKeyField, userCreatedField, sortField };
return { info, fields, defaults, primaryKeyField, userCreatedField, sortField };
}

View File

@@ -9,14 +9,14 @@ import { APIError } from '@/types';
export function useItem(collection: Ref<string>, primaryKey: Ref<string | number | null>) {
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
const item = ref<any>(null);
const item = ref<Record<string, any> | null>(null);
const error = ref(null);
const validationErrors = ref([]);
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const archiving = ref(false);
const edits = ref({});
const edits = ref<Record<string, any>>({});
const isNew = computed(() => primaryKey.value === '+');
const isBatch = computed(() => typeof primaryKey.value === 'string' && primaryKey.value.includes(','));
const isSingle = computed(() => !!collectionInfo.value?.meta?.singleton);
@@ -203,7 +203,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
if (unarchiveValue === 'false') unarchiveValue = false;
try {
let value: any = item.value[field] === archiveValue ? unarchiveValue : archiveValue;
let value: any = item.value && item.value[field] === archiveValue ? unarchiveValue : archiveValue;
if (value === 'true') value = true;
if (value === 'false') value = false;

View File

@@ -20,6 +20,8 @@ type Query = {
export function useItems(collection: Ref<string>, query: Query) {
const { primaryKeyField, sortField } = useCollection(collection);
let loadingTimeout: any = null;
const { limit, fields, sort, page, filters, searchQuery } = query;
const endpoint = computed(() => {
@@ -103,9 +105,11 @@ export function useItems(collection: Ref<string>, query: Query) {
return { itemCount, totalCount, items, totalPages, loading, error, changeManualSort, getItems };
async function getItems() {
if (loadingTimeout) return;
error.value = null;
const loadingTimeout = setTimeout(() => {
loadingTimeout = setTimeout(() => {
loading.value = true;
}, 250);
@@ -182,6 +186,7 @@ export function useItems(collection: Ref<string>, query: Query) {
error.value = err;
} finally {
clearTimeout(loadingTimeout);
loadingTimeout = null;
loading.value = false;
}
}

View File

@@ -20,6 +20,10 @@ export default defineComponent({
required: true,
validator: (val: string) => ['dateTime', 'date', 'time', 'timestamp'].includes(val),
},
format: {
type: String,
default: 'long',
},
relative: {
type: Boolean,
default: false,
@@ -57,9 +61,18 @@ export default defineComponent({
addSuffix: true,
});
} else {
let format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
let format;
if (props.format === 'long') {
format = `${i18n.t('date-fns_date')} ${i18n.t('date-fns_time')}`;
if (props.type === 'date') format = String(i18n.t('date-fns_date'));
if (props.type === 'time') format = String(i18n.t('date-fns_time'));
} else if (props.format === 'short') {
format = `${i18n.t('date-fns_date_short')} ${i18n.t('date-fns_time_short')}`;
if (props.type === 'date') format = String(i18n.t('date-fns_date_short'));
if (props.type === 'time') format = String(i18n.t('date-fns_time_short'));
} else {
format = props.format;
}
displayValue.value = await localizedFormat(newValue, format);
}

View File

@@ -8,11 +8,32 @@ export default defineDisplay(({ i18n }) => ({
icon: 'query_builder',
handler: DisplayDateTime,
options: [
{
field: 'format',
name: i18n.t('displays.datetime.format'),
type: 'string',
meta: {
interface: 'dropdown',
width: 'half',
options: {
choices: [
{ text: i18n.t('displays.datetime.long'), value: 'long' },
{ text: i18n.t('displays.datetime.short'), value: 'short' },
],
allowOther: true,
},
note: i18n.t('displays.datetime.format_note'),
},
schema: {
default_value: 'long',
},
},
{
field: 'relative',
name: i18n.t('displays.datetime.relative'),
type: 'boolean',
meta: {
width: 'half',
interface: 'toggle',
options: {
label: i18n.t('displays.datetime.relative_label'),

View File

@@ -1,5 +1,5 @@
<template>
<v-notice v-if="!collectionField" type="warning">
<v-notice v-if="!collectionField && !collection" type="warning">
{{ $t('collection_field_not_setup') }}
</v-notice>
<v-notice v-else-if="selectItems.length === 0" type="warning">
@@ -27,6 +27,10 @@ export default defineComponent({
type: String,
default: null,
},
collection: {
type: String,
default: null,
},
typeAllowList: {
type: Array as PropType<string[]>,
default: () => [],
@@ -54,8 +58,8 @@ export default defineComponent({
const values = inject('values', ref<Record<string, any>>({}));
const fields = computed(() => {
if (!props.collectionField) return [];
return fieldsStore.getFieldsForCollection(values.value[props.collectionField]);
if (!props.collectionField && !props.collection) return [];
return fieldsStore.getFieldsForCollection(props.collection || values.value[props.collectionField]);
});
const selectItems = computed(() =>
@@ -74,7 +78,7 @@ export default defineComponent({
})
);
return { selectItems };
return { selectItems, values };
},
});
</script>

View File

@@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({
icon: 'note_add',
component: InterfaceFile,
types: ['uuid'],
localTypes: ['file'],
relationship: 'm2o',
options: [],
recommendedDisplays: ['file'],

View File

@@ -97,6 +97,10 @@ export default defineComponent({
type: String,
required: true,
},
sortField: {
type: String,
default: null,
},
value: {
type: Array as PropType<(string | number | Record<string, any>)[] | null>,
default: null,
@@ -107,7 +111,7 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const { collection, field, value, primaryKey } = toRefs(props);
const { collection, field, value, primaryKey, sortField } = toRefs(props);
const { junction, junctionCollection, relation, relationCollection, relationInfo } = useRelation(
collection,
@@ -154,6 +158,7 @@ export default defineComponent({
const { loading, error, items } = usePreview(
value,
fields,
sortField,
relationInfo,
getNewSelectedItems,
getUpdatedItems,

View File

@@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({
icon: 'note_add',
component: InterfaceFiles,
types: ['alias'],
localTypes: ['files'],
relationship: 'm2m',
options: [],
recommendedDisplays: ['files'],

View File

@@ -12,14 +12,24 @@
"account_box",
"account_circle",
"add_shopping_cart",
"addchart",
"admin_panel_settings",
"alarm",
"alarm_add",
"alarm_off",
"alarm_on",
"all_inbox",
"all_out",
"analytics",
"anchor",
"android",
"announcement",
"api",
"app_blocking",
"arrow_circle_down",
"arrow_circle_up",
"arrow_right_alt",
"article",
"aspect_ratio",
"assessment",
"assignment",
@@ -30,15 +40,21 @@
"assignment_turned_in",
"autorenew",
"backup",
"backup_table",
"batch_prediction",
"book",
"book_online",
"bookmark",
"bookmark_border",
"bookmarks",
"bug_report",
"build",
"build_circle",
"cached",
"calendar_today",
"calendar_view_day",
"camera_enhance",
"cancel_schedule_send",
"card_giftcard",
"card_membership",
"card_travel",
@@ -47,10 +63,13 @@
"check_circle_outline",
"chrome_reader_mode",
"class",
"close_fullscreen",
"code",
"comment_bank",
"commute",
"compare_arrows",
"contact_support",
"contactless",
"copyright",
"credit_card",
"dashboard",
@@ -66,20 +85,26 @@
"donut_large",
"donut_small",
"drag_indicator",
"dynamic_form",
"eco",
"eject",
"euro_symbol",
"event",
"event_seat",
"exit_to_app",
"explore",
"explore_off",
"extension",
"face",
"fact_check",
"favorite",
"favorite_border",
"feedback",
"filter_alt",
"find_in_page",
"find_replace",
"fingerprint",
"flaky",
"flight_land",
"flight_takeoff",
"flip_to_back",
@@ -89,13 +114,18 @@
"get_app",
"gif",
"grade",
"grading",
"group_work",
"help",
"help_center",
"help_outline",
"highlight_alt",
"highlight_off",
"history",
"history_toggle_off",
"home",
"horizontal_split",
"hourglass_disabled",
"hourglass_empty",
"hourglass_full",
"http",
@@ -103,9 +133,11 @@
"important_devices",
"info",
"input",
"integration_instructions",
"invert_colors",
"label",
"label_important",
"label_off",
"language",
"launch",
"line_style",
@@ -113,21 +145,28 @@
"list",
"lock",
"lock_open",
"login",
"loyalty",
"markunread_mailbox",
"maximize",
"mediation",
"minimize",
"motorcycle",
"note_add",
"offline_bolt",
"offline_pin",
"online_prediction",
"opacity",
"open_in_browser",
"open_in_full",
"open_in_new",
"open_with",
"outlet",
"pageview",
"pan_tool",
"payment",
"pending",
"pending_actions",
"perm_camera_mic",
"perm_contact_calendar",
"perm_data_setting",
@@ -139,27 +178,34 @@
"pets",
"picture_in_picture",
"picture_in_picture_alt",
"plagiarism",
"play_for_work",
"polymer",
"power_settings_new",
"pregnant_woman",
"preview",
"print",
"privacy_tip",
"query_builder",
"question_answer",
"quickreply",
"receipt",
"record_voice_over",
"redeem",
"remove_shopping_cart",
"reorder",
"report_problem",
"request_page",
"restore",
"restore_from_trash",
"restore_page",
"room",
"rounded_corner",
"rowing",
"rule",
"schedule",
"search",
"search_off",
"settings",
"settings_applications",
"settings_backup_restore",
@@ -179,24 +225,35 @@
"settings_voice",
"shop",
"shop_two",
"shopping_bag",
"shopping_basket",
"shopping_cart",
"smart_button",
"source",
"speaker_notes",
"speaker_notes_off",
"spellcheck",
"star_rate",
"stars",
"store",
"subject",
"subtitles_off",
"supervised_user_circle",
"supervisor_account",
"support",
"swap_horiz",
"swap_horizontal_circle",
"swap_vert",
"swap_vertical_circle",
"sync_alt",
"system_update_alt",
"tab",
"tab_unselected",
"table_view",
"text_rotate_up",
"text_rotate_vertical",
"text_rotation_angledown",
"text_rotation_angleup",
"text_rotation_down",
"text_rotation_none",
"theaters",
@@ -208,6 +265,7 @@
"today",
"toll",
"touch_app",
"tour",
"track_changes",
"translate",
"trending_down",
@@ -216,6 +274,8 @@
"turned_in",
"turned_in_not",
"update",
"upgrade",
"verified",
"verified_user",
"vertical_split",
"view_agenda",
@@ -227,15 +287,18 @@
"view_list",
"view_module",
"view_quilt",
"view_sidebar",
"view_stream",
"view_week",
"visibility",
"visibility_off",
"voice_over_off",
"watch_later",
"wifi_protected_setup",
"work",
"work_off",
"work_outline",
"wysiwyg",
"youtube_searched_for",
"zoom_in",
"zoom_out"
@@ -245,6 +308,7 @@
"name": "alert",
"icons": [
"add_alert",
"auto_delete",
"error",
"error_outline",
"notification_important",
@@ -255,6 +319,7 @@
"name": "av",
"icons": [
"4k",
"5g",
"add_to_queue",
"airplay",
"album",
@@ -263,6 +328,7 @@
"branding_watermark",
"call_to_action",
"closed_caption",
"closed_caption_disabled",
"control_camera",
"equalizer",
"explicit",
@@ -281,8 +347,10 @@
"games",
"hd",
"hearing",
"hearing_disabled",
"high_quality",
"library_add",
"library_add_check",
"library_books",
"library_music",
"loop",
@@ -300,6 +368,7 @@
"pause_circle_outline",
"play_arrow",
"play_circle_filled",
"play_circle_filled_white",
"play_circle_outline",
"playlist_add",
"playlist_add_check",
@@ -322,13 +391,16 @@
"slow_motion_video",
"snooze",
"sort_by_alpha",
"speed",
"stop",
"stop_circle",
"subscriptions",
"subtitles",
"surround_sound",
"video_call",
"video_label",
"video_library",
"video_settings",
"videocam",
"videocam_off",
"volume_down",
@@ -342,6 +414,7 @@
{
"name": "communication",
"icons": [
"add_ic_call",
"alternate_email",
"business",
"call",
@@ -353,7 +426,7 @@
"call_received",
"call_split",
"cancel_presentation",
"cell_wifi",
"wifi",
"chat",
"chat_bubble",
"chat_bubble_outline",
@@ -362,11 +435,17 @@
"contact_mail",
"contact_phone",
"contacts",
"desktop_access_disabled",
"dialer_sip",
"dialpad",
"domain_disabled",
"domain_verification",
"duo",
"email",
"forum",
"forward_to_inbox",
"hourglass_bottom",
"hourglass_top",
"import_contacts",
"import_export",
"invert_colors_off",
@@ -375,17 +454,31 @@
"location_off",
"location_on",
"mail_outline",
"mark_chat_read",
"mark_chat_unread",
"mark_email_read",
"mark_email_unread",
"message",
"mobile_screen_share",
"more_time",
"nat",
"no_sim",
"pause_presentation",
"person_add_disabled",
"person_search",
"phone",
"phone_disabled",
"phone_enabled",
"phonelink_erase",
"phonelink_lock",
"phonelink_ring",
"phonelink_setup",
"portable_wifi_off",
"present_to_all",
"print_disabled",
"qr_code",
"qr_code_scanner",
"read_more",
"ring_volume",
"rss_feed",
"screen_share",
@@ -400,7 +493,8 @@
"textsms",
"unsubscribe",
"voicemail",
"vpn_key"
"vpn_key",
"wifi_calling"
]
},
{
@@ -410,14 +504,21 @@
"add_box",
"add_circle",
"add_circle_outline",
"amp_stories",
"archive",
"backspace",
"ballot",
"biotech",
"block",
"calculate",
"clear",
"content_copy",
"content_cut",
"content_paste",
"create",
"delete_sweep",
"drafts",
"dynamic_feed",
"file_copy",
"filter_list",
"flag",
@@ -427,6 +528,7 @@
"how_to_reg",
"how_to_vote",
"inbox",
"insights",
"link",
"link_off",
"low_priority",
@@ -435,6 +537,8 @@
"move_to_inbox",
"next_week",
"outlined_flag",
"policy",
"push_pin",
"redo",
"remove",
"remove_circle",
@@ -448,6 +552,7 @@
"select_all",
"send",
"sort",
"square_foot",
"text_format",
"unarchive",
"undo",
@@ -462,6 +567,7 @@
"access_alarm",
"access_alarms",
"access_time",
"ad_units",
"add_alarm",
"add_to_home_screen",
"airplanemode_active",
@@ -469,7 +575,6 @@
"battery_alert",
"battery_charging_full",
"battery_full",
"battery_std",
"battery_unknown",
"bluetooth",
"bluetooth_connected",
@@ -484,15 +589,11 @@
"devices",
"dvr",
"gps_fixed",
"gps_not_fixed",
"gps_off",
"graphic_eq",
"location_disabled",
"location_searching",
"mobile_friendly",
"mobile_off",
"network_cell",
"network_wifi",
"nfc",
"screen_lock_landscape",
"screen_lock_portrait",
@@ -523,7 +624,6 @@
"border_all",
"border_bottom",
"border_clear",
"border_color",
"border_horizontal",
"border_inner",
"border_left",
@@ -540,9 +640,7 @@
"format_align_right",
"format_bold",
"format_clear",
"format_color_fill",
"format_color_reset",
"format_color_text",
"format_indent_decrease",
"format_indent_increase",
"format_italic",
@@ -559,9 +657,10 @@
"format_textdirection_r_to_l",
"format_underlined",
"functions",
"height",
"highlight",
"horizontal_rule",
"insert_chart",
"insert_chart_outlined",
"insert_comment",
"insert_drive_file",
"insert_emoticon",
@@ -576,6 +675,7 @@
"multiline_chart",
"notes",
"pie_chart",
"post_add",
"publish",
"scatter_plot",
"score",
@@ -583,7 +683,10 @@
"show_chart",
"space_bar",
"strikethrough_s",
"subscript",
"superscript",
"table_chart",
"table_rows",
"text_fields",
"title",
"vertical_align_bottom",
@@ -595,6 +698,7 @@
{
"name": "file",
"icons": [
"attach_email",
"attachment",
"cloud",
"cloud_circle",
@@ -606,14 +710,21 @@
"create_new_folder",
"folder",
"folder_open",
"folder_shared"
"folder_shared",
"request_quote",
"rule_folder",
"snippet_folder",
"text_snippet",
"topic"
]
},
{
"name": "hardware",
"icons": [
"browser_not_supported",
"cast",
"cast_connected",
"cast_for_education",
"computer",
"desktop_mac",
"desktop_windows",
@@ -646,6 +757,7 @@
"phone_iphone",
"phonelink",
"phonelink_off",
"point_of_sale",
"power_input",
"router",
"scanner",
@@ -663,6 +775,13 @@
"watch"
]
},
{
"name": "home",
"icons": [
"sensor_door",
"sensor_window"
]
},
{
"name": "image",
"icons": [
@@ -673,6 +792,7 @@
"assistant",
"assistant_photo",
"audiotrack",
"bedtime",
"blur_circular",
"blur_linear",
"blur_off",
@@ -716,6 +836,7 @@
"dehaze",
"details",
"edit",
"euro",
"exposure",
"exposure_neg_1",
"exposure_neg_2",
@@ -746,6 +867,8 @@
"flash_off",
"flash_on",
"flip",
"flip_camera_android",
"flip_camera_ios",
"gradient",
"grain",
"grid_off",
@@ -757,6 +880,7 @@
"healing",
"image",
"image_aspect_ratio",
"image_not_supported",
"image_search",
"iso",
"landscape",
@@ -773,6 +897,8 @@
"looks_two",
"loupe",
"monochrome_photos",
"motion_photos_on",
"motion_photos_paused",
"movie_creation",
"movie_filter",
"music_note",
@@ -797,6 +923,7 @@
"photo_size_select_small",
"picture_as_pdf",
"portrait",
"receipt_long",
"remove_red_eye",
"rotate_90_degrees_ccw",
"rotate_left",
@@ -831,12 +958,20 @@
"name": "maps",
"icons": [
"360",
"add_business",
"add_location",
"add_location_alt",
"add_road",
"agriculture",
"alt_route",
"atm",
"beenhere",
"bike_scooter",
"category",
"cleaning_services",
"compass_calibration",
"departure_board",
"design_services",
"directions",
"directions_bike",
"directions_boat",
@@ -849,10 +984,19 @@
"directions_walk",
"edit_attributes",
"edit_location",
"edit_road",
"electric_bike",
"electric_car",
"electric_moped",
"electric_scooter",
"electrical_services",
"ev_station",
"fastfood",
"flight",
"handyman",
"home_repair_service",
"hotel",
"hvac",
"layers",
"layers_clear",
"local_activity",
@@ -864,6 +1008,7 @@
"local_convenience_store",
"local_dining",
"local_drink",
"local_fire_department",
"local_florist",
"local_gas_station",
"local_grocery_store",
@@ -879,24 +1024,39 @@
"local_phone",
"local_pizza",
"local_play",
"local_police",
"local_post_office",
"local_printshop",
"local_see",
"local_shipping",
"local_taxi",
"map",
"maps_ugc",
"medical_services",
"menu_book",
"miscellaneous_services",
"money",
"moped",
"multiple_stop",
"museum",
"my_location",
"navigation",
"near_me",
"no_meals",
"no_transfer",
"not_listed_location",
"pedal_bike",
"person_pin",
"person_pin_circle",
"pest_control",
"pest_control_rodent",
"pin_drop",
"place",
"plumbing",
"rate_review",
"restaurant",
"restaurant_menu",
"run_circle",
"satellite",
"store_mall_directory",
"streetview",
@@ -908,12 +1068,15 @@
"transfer_within_a_station",
"transit_enterexit",
"trip_origin",
"two_wheeler",
"wrong_location",
"zoom_out_map"
]
},
{
"name": "navigation",
"icons": [
"app_settings_alt",
"apps",
"arrow_back",
"arrow_back_ios",
@@ -926,30 +1089,47 @@
"arrow_left",
"arrow_right",
"arrow_upward",
"campaign",
"cancel",
"check",
"chevron_left",
"chevron_right",
"close",
"double_arrow",
"east",
"expand_less",
"expand_more",
"first_page",
"fullscreen",
"fullscreen_exit",
"home_work",
"last_page",
"legend_toggle",
"menu",
"menu_open",
"more_horiz",
"more_vert",
"north",
"north_east",
"north_west",
"payments",
"refresh",
"south",
"south_east",
"south_west",
"subdirectory_arrow_left",
"subdirectory_arrow_right",
"switch_left",
"switch_right",
"unfold_less",
"unfold_more"
"unfold_more",
"west"
]
},
{
"name": "notification",
"icons": [
"account_tree",
"adb",
"airline_seat_flat",
"airline_seat_flat_angled",
@@ -961,6 +1141,7 @@
"airline_seat_recline_normal",
"bluetooth_audio",
"confirmation_number",
"directions_off",
"disc_full",
"drive_eta",
"enhanced_encryption",
@@ -989,6 +1170,7 @@
"sd_card",
"sms",
"sms_failed",
"support_agent",
"sync",
"sync_disabled",
"sync_problem",
@@ -1010,57 +1192,135 @@
"ac_unit",
"airport_shuttle",
"all_inclusive",
"apartment",
"baby_changing_station",
"backpack",
"bathtub",
"beach_access",
"business_center",
"casino",
"charging_station",
"checkroom",
"child_care",
"child_friendly",
"corporate_fare",
"do_not_step",
"do_not_touch",
"dry",
"elevator",
"escalator",
"escalator_warning",
"family_restroom",
"fire_extinguisher",
"fitness_center",
"free_breakfast",
"golf_course",
"grass",
"hot_tub",
"house",
"kitchen",
"meeting_room",
"no_cell",
"no_drinks",
"no_flash",
"no_food",
"no_meeting_room",
"no_photography",
"no_stroller",
"pool",
"room_preferences",
"room_service",
"rv_hookup",
"smoke_free",
"smoking_rooms",
"spa"
"soap",
"spa",
"stairs",
"storefront",
"stroller",
"tty",
"umbrella",
"wash",
"wheelchair_pickup"
]
},
{
"name": "social",
"icons": [
"6_ft_apart",
"architecture",
"cake",
"clean_hands",
"construction",
"deck",
"domain",
"emoji_emotions",
"emoji_events",
"emoji_flags",
"emoji_food_beverage",
"emoji_nature",
"emoji_objects",
"emoji_people",
"emoji_symbols",
"emoji_transportation",
"engineering",
"fireplace",
"group",
"group_add",
"history_edu",
"king_bed",
"location_city",
"luggage",
"military_tech",
"mood",
"mood_bad",
"nights_stay",
"no_luggage",
"notifications",
"notifications_active",
"notifications_none",
"notifications_off",
"notifications_paused",
"outdoor_grill",
"pages",
"party_mode",
"people",
"people_outline",
"people_alt",
"person",
"person_add",
"person_add_alt_1",
"person_outline",
"person_remove",
"plus_one",
"poll",
"psychology",
"public",
"public_off",
"school",
"science",
"self_improvement",
"sentiment_dissatisfied",
"sentiment_satisfied",
"sentiment_very_dissatisfied",
"sentiment_very_satisfied",
"share",
"single_bed",
"sports",
"sports_baseball",
"sports_basketball",
"sports_cricket",
"sports_esports",
"sports_football",
"sports_golf",
"sports_handball",
"sports_hockey",
"sports_kabaddi",
"sports_mma",
"sports_motorsports",
"sports_rugby",
"sports_soccer",
"sports_tennis",
"sports_volleyball",
"thumb_down_alt",
"thumb_up_alt",
"whatshot"
@@ -1076,7 +1336,9 @@
"radio_button_unchecked",
"star",
"star_border",
"star_half"
"star_half",
"toggle_off",
"toggle_on"
]
}
]

View File

@@ -8,6 +8,7 @@ export default defineInterface(({ i18n }) => ({
icon: 'insert_photo',
component: InterfaceImage,
types: ['uuid'],
localTypes: ['file'],
relationship: 'm2o',
options: [],
recommendedDisplays: ['image'],

View File

@@ -10,6 +10,7 @@ export default defineInterface(({ i18n }) => ({
component: InterfaceManyToMany,
relationship: 'm2m',
types: ['alias'],
localTypes: ['m2m'],
options: Options,
recommendedDisplays: ['related-values'],
}));

View File

@@ -5,12 +5,16 @@
<div class="one-to-many" v-else>
<v-table
:loading="loading"
:items="items"
:items="sortedItems || items"
:headers.sync="tableHeaders"
show-resize
inline
:sort.sync="sort"
@update:items="sortItems($event)"
@click:row="editItem"
:disabled="disabled"
:show-manual-sort="sortField !== null"
:manual-sort-key="sortField"
>
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
<render-display
@@ -73,6 +77,7 @@ import useRelation from './use-relation';
import usePreview from './use-preview';
import useEdit from './use-edit';
import useSelection from './use-selection';
import useSort from './use-sort';
export default defineComponent({
components: { DrawerItem, DrawerCollection },
@@ -93,6 +98,10 @@ export default defineComponent({
type: String,
required: true,
},
sortField: {
type: String,
default: null,
},
fields: {
type: Array as PropType<string[]>,
default: () => [],
@@ -103,7 +112,7 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const { value, collection, field, fields } = toRefs(props);
const { value, collection, field, fields, sortField } = toRefs(props);
function emitter(newVal: any[] | null) {
emit('input', newVal);
@@ -127,6 +136,7 @@ export default defineComponent({
const { tableHeaders, items, loading, error } = usePreview(
value,
fields,
sortField,
relationInfo,
getNewSelectedItems,
getUpdatedItems,
@@ -151,6 +161,8 @@ export default defineComponent({
emitter
);
const { sort, sortItems, sortedItems } = useSort(sortField, fields, items, emitter);
return {
junction,
relation,
@@ -172,6 +184,9 @@ export default defineComponent({
relatedPrimaryKey,
get,
editModalActive,
sort,
sortItems,
sortedItems,
};
},
});

View File

@@ -13,6 +13,15 @@
"
/>
</div>
<div class="field half">
<p class="type-label">{{ $t('sort_field') }}</p>
<interface-field
v-model="sortField"
:collection="junctionCollection"
:type-allow-list="['bigInteger', 'integer']"
allowNone
></interface-field>
</div>
</div>
</template>
@@ -52,6 +61,19 @@ export default defineComponent({
setup(props, { emit }) {
const collectionsStore = useCollectionsStore();
const relationsStore = useRelationsStore();
const sortField = computed({
get() {
return props.value?.sortField;
},
set(newFields: string) {
emit('input', {
...(props.value || {}),
sortField: newFields,
});
},
});
const fields = computed({
get() {
return props.value?.fields;
@@ -63,6 +85,7 @@ export default defineComponent({
});
},
});
const junctionCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
@@ -71,12 +94,14 @@ export default defineComponent({
);
return junctionRelation?.many_collection || null;
});
const junctionCollectionExists = computed(() => {
return !!collectionsStore.state.collections.find(
(collection) => collection.collection === junctionCollection.value
);
});
return { fields, junctionCollection, junctionCollectionExists };
return { fields, sortField, junctionCollection, junctionCollectionExists };
},
});
</script>

View File

@@ -9,6 +9,7 @@ import { cloneDeep, get } from 'lodash';
export default function usePreview(
value: Ref<(string | number | Record<string, any>)[] | null>,
fields: Ref<string[]>,
sortField: Ref<string | null>,
relation: Ref<RelationInfo>,
getNewSelectedItems: () => Record<string, any>[],
getUpdatedItems: () => Record<string, any>[],
@@ -29,7 +30,7 @@ export default function usePreview(
return fields.reduce((acc: string[], field) => {
const sections = field.split('.');
if (junctionField === sections[0] && sections.length === 2) acc.push(sections[1]);
if (junctionField === sections[0] && sections.length >= 2) acc.push(sections.slice(1).join('.'));
return acc;
}, []);
}
@@ -124,6 +125,9 @@ export default function usePreview(
if (filteredFields.includes(junctionPkField) === false) filteredFields.push(junctionPkField);
if (filteredFields.includes(junctionField) === false) filteredFields.push(junctionField);
if (sortField.value !== null && filteredFields.includes(sortField.value) === false)
filteredFields.push(sortField.value);
data = await request(junctionCollection, filteredFields, junctionPkField, primaryKeys);
}

View File

@@ -0,0 +1,35 @@
import { Ref, ref, computed } from '@vue/composition-api';
import { Sort } from '@/components/v-table/types';
import { sortBy } from 'lodash';
export default function useSort(
sortField: Ref<string | null>,
fields: Ref<string[]>,
items: Ref<Record<string, any>[]>,
emit: (newVal: any[] | null) => void
) {
const sort = ref<Sort>({ by: sortField.value || fields.value[0], desc: false });
function sortItems(newItems: Record<string, any>[]) {
const sField = sortField.value;
if (sField === null) return;
const itemsSorted = newItems.map((item, i) => {
item[sField] = i;
return item;
});
emit(itemsSorted);
}
const sortedItems = computed(() => {
const sField = sortField.value;
if (sField === null || sort.value.by !== sField) return null;
const desc = sort.value.desc;
const sorted = sortBy(items.value, [sField]);
return desc ? sorted.reverse() : sorted;
});
return { sort, sortItems, sortedItems };
}

View File

@@ -10,6 +10,7 @@ export default defineInterface(({ i18n }) => ({
component: InterfaceManyToOne,
types: ['uuid', 'string', 'text', 'integer', 'bigInteger'],
relationship: 'm2o',
localTypes: ['m2o'],
options: Options,
recommendedDisplays: ['related-values'],
}));

View File

@@ -9,6 +9,7 @@ export default defineInterface(({ i18n }) => ({
icon: 'arrow_right_alt',
component: InterfaceOneToMany,
types: ['alias'],
localTypes: ['o2m'],
relationship: 'o2m',
options: Options,
recommendedDisplays: ['related-values'],

View File

@@ -5,12 +5,16 @@
<div class="one-to-many" v-else>
<v-table
:loading="loading"
:items="displayItems"
:items="sortedItems || items"
:headers.sync="tableHeaders"
show-resize
inline
:sort.sync="sort"
@update:items="sortItems($event)"
@click:row="editItem"
:disabled="disabled"
:show-manual-sort="sortField !== null"
:manual-sort-key="sortField"
>
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
<render-display
@@ -68,8 +72,8 @@ import { useCollectionsStore, useRelationsStore, useFieldsStore } from '@/stores
import DrawerItem from '@/views/private/components/drawer-item';
import DrawerCollection from '@/views/private/components/drawer-collection';
import { Filter, Field } from '@/types';
import { Header } from '@/components/v-table/types';
import { isEqual } from 'lodash';
import { Header, Sort } from '@/components/v-table/types';
import { isEqual, sortBy } from 'lodash';
export default defineComponent({
components: { DrawerItem, DrawerCollection },
@@ -94,6 +98,10 @@ export default defineComponent({
type: Array as PropType<string[]>,
default: () => [],
},
sortField: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
@@ -105,9 +113,10 @@ export default defineComponent({
const fieldsStore = useFieldsStore();
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
const { tableHeaders, displayItems, loading, error } = useTable();
const { tableHeaders, items, loading, error } = useTable();
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdits();
const { stageSelection, selectModalActive, selectionFilters } = useSelection();
const { sort, sortItems, sortedItems } = useSort();
return {
relation,
@@ -122,8 +131,11 @@ export default defineComponent({
stageSelection,
selectModalActive,
deleteItem,
displayItems,
items,
sortItems,
selectionFilters,
sort,
sortedItems,
};
function getItem(id: string | number) {
@@ -200,6 +212,31 @@ export default defineComponent({
);
}
function useSort() {
const sort = ref<Sort>({ by: props.sortField || props.fields[0], desc: false });
function sortItems(newItems: Record<string, any>[]) {
if (props.sortField === null) return;
const itemsSorted = newItems.map((item, i) => {
item[props.sortField] = i;
return item;
});
emit('input', itemsSorted);
}
const sortedItems = computed(() => {
if (props.sortField === null || sort.value.by !== props.sortField) return null;
const desc = sort.value.desc;
const sorted = sortBy(items.value, [props.sortField]);
return desc ? sorted.reverse() : sorted;
});
return { sort, sortItems, sortedItems };
}
/**
* Holds info about the current relationship, like related collection, primary key field
* of the other collection etc
@@ -223,7 +260,7 @@ export default defineComponent({
// values if it needs to. This allows the user to manually resize the columns for example
const tableHeaders = ref<Header[]>([]);
const loading = ref(false);
const displayItems = ref<Record<string, any>[]>([]);
const items = ref<Record<string, any>[]>([]);
const error = ref(null);
watch(
@@ -238,6 +275,9 @@ export default defineComponent({
fields.push(pkField);
}
if (props.sortField !== null && fields.includes(props.sortField) === false)
fields.push(props.sortField);
try {
const endpoint = relatedCollection.value.collection.startsWith('directus_')
? `/${relatedCollection.value.collection.substring(9)}`
@@ -261,7 +301,7 @@ export default defineComponent({
const updatedItems = getUpdatedItems();
const newItems = getNewItems();
displayItems.value = existingItems
items.value = existingItems
.map((item) => {
const updatedItem = updatedItems.find((updated) => updated[pkField] === item[pkField]);
if (updatedItem !== undefined) return updatedItem;
@@ -311,7 +351,7 @@ export default defineComponent({
{ immediate: true }
);
return { tableHeaders, displayItems, loading, error };
return { tableHeaders, items, loading, error };
}
function useEdits() {
@@ -376,11 +416,11 @@ export default defineComponent({
const selectModalActive = ref(false);
const selectedPrimaryKeys = computed<(number | string)[]>(() => {
if (displayItems.value === null) return [];
if (items.value === null) return [];
const pkField = relatedPrimaryKeyField.value.field;
return displayItems.value
return items.value
.filter((currentItem) => pkField in currentItem)
.map((currentItem) => currentItem[pkField]);
});

View File

@@ -11,6 +11,15 @@
:inject="relatedCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
/>
</div>
<div class="field half">
<p class="type-label">{{ $t('sort_field') }}</p>
<interface-field
v-model="sortField"
:collection="relatedCollection"
:type-allow-list="['bigInteger', 'integer']"
allowNone
></interface-field>
</div>
</div>
</template>
@@ -62,6 +71,18 @@ export default defineComponent({
},
});
const sortField = computed({
get() {
return props.value?.sortField;
},
set(newFields: string) {
emit('input', {
...(props.value || {}),
sortField: newFields,
});
},
});
const relatedCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
@@ -77,7 +98,7 @@ export default defineComponent({
);
});
return { fields, relatedCollection, relatedCollectionExists };
return { fields, sortField, relatedCollection, relatedCollectionExists };
},
});
</script>

View File

@@ -40,7 +40,7 @@ export default defineComponent({
set(newVal: FieldMeta[] | null) {
const fields = (newVal || []).map((meta: Record<string, any>) => ({
field: meta.field,
name: meta.field,
name: meta.name || meta.field,
type: meta.type,
meta,
}));
@@ -53,6 +53,21 @@ export default defineComponent({
});
const repeaterFields: DeepPartial<Field>[] = [
{
name: i18n.tc('name'),
field: 'name',
type: 'string',
meta: {
interface: 'text-input',
width: 'full',
sort: 1,
options: {
font: 'monospace',
placeholder: i18n.t('interfaces.repeater.field_name_placeholder'),
},
},
schema: null,
},
{
name: i18n.tc('field', 1),
field: 'field',
@@ -60,7 +75,7 @@ export default defineComponent({
meta: {
interface: 'text-input',
width: 'half',
sort: 1,
sort: 2,
options: {
font: 'monospace',
placeholder: i18n.t('interfaces.repeater.field_name_placeholder'),
@@ -75,7 +90,7 @@ export default defineComponent({
meta: {
interface: 'dropdown',
width: 'half',
sort: 2,
sort: 3,
options: {
choices: [
{
@@ -98,13 +113,13 @@ export default defineComponent({
meta: {
interface: 'dropdown',
width: 'half',
sort: 3,
sort: 4,
options: {
choices: fieldTypes
}
choices: fieldTypes,
},
},
schema: {
default_value: 'string'
default_value: 'string',
},
},
{
@@ -114,10 +129,10 @@ export default defineComponent({
meta: {
interface: 'interface',
width: 'half',
sort: 4,
sort: 5,
options: {
typeField: 'type'
}
typeField: 'type',
},
},
schema: null,
},
@@ -128,7 +143,7 @@ export default defineComponent({
meta: {
interface: 'text-input',
width: 'full',
sort: 5,
sort: 6,
options: {
placeholder: i18n.t('interfaces.repeater.field_note_placeholder'),
},
@@ -142,7 +157,7 @@ export default defineComponent({
meta: {
interface: 'interface-options',
width: 'full',
sort: 6,
sort: 7,
options: {
interfaceField: 'interface',
},

View File

@@ -1,5 +1,5 @@
<template>
<v-item class="row" v-slot:default="{ active, toggle }" :active="true" :watch="false">
<v-item class="row" v-slot:default="{ active, toggle }" :active="initialActive" :watch="false">
<repeater-row-header
:template="template"
:value="value"
@@ -39,6 +39,10 @@ export default defineComponent({
type: Array as PropType<Partial<Field>[]>,
default: () => [],
},
initialActive: {
type: Boolean,
default: false,
},
template: {
type: String,
required: true,

View File

@@ -11,6 +11,7 @@
@delete="removeItem(row)"
:disabled="disabled"
:headerPlaceholder="headerPlaceholder"
:initialActive="addedIndex === index"
/>
</draggable>
<button @click="addNew" class="add-new" v-if="showAddNew">
@@ -32,7 +33,7 @@ export default defineComponent({
components: { RepeaterRow, Draggable },
props: {
value: {
type: Array,
type: Array as PropType<Record<string, any>[]>,
default: null,
},
fields: {
@@ -61,7 +62,7 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const selection = ref<number[]>([]);
const addedIndex = ref<number | null>(null);
const _template = computed(() => {
if (props.template === null) return props.fields.length > 0 ? `{{${props.fields[0].field}}}` : '';
@@ -76,11 +77,11 @@ export default defineComponent({
return false;
});
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage, selection, _template };
return { updateValues, onSort, removeItem, addNew, showAddNew, hideDragImage, addedIndex, _template };
function updateValues(index: number, updatedValues: any) {
emitValue(
props.value.map((item, i) => {
props.value.map((item: any, i: number) => {
if (i === index) {
return updatedValues;
}
@@ -95,7 +96,7 @@ export default defineComponent({
}
function removeItem(row: any) {
selection.value = [];
addedIndex.value = null;
if (props.value) {
emitValue(props.value.filter((existingItem) => existingItem !== row));
} else {
@@ -111,6 +112,8 @@ export default defineComponent({
newDefaults[field.field!] = field.schema?.default_value;
});
addedIndex.value = props.value === null ? 0 : props.value.length;
if (props.value !== null) {
emitValue([...props.value, newDefaults]);
} else {

View File

@@ -131,7 +131,7 @@ export default defineComponent({
if (props.capitalization === 'auto-format') val = formatTitle(val, new RegExp(whitespace));
val = val.replaceAll(/ +/g, whitespace);
val = val.replace(/ +/g, whitespace);
return val;
});

View File

@@ -1,6 +1,6 @@
import VueI18n from 'vue-i18n';
import { Component } from 'vue';
import { Field, types } from '@/types';
import { Field, types, localTypes } from '@/types';
export type InterfaceConfig = {
id: string;
@@ -10,6 +10,7 @@ export type InterfaceConfig = {
component: Component;
options: DeepPartial<Field>[] | Component;
types: typeof types[number][];
localTypes?: readonly typeof localTypes[number][];
relationship?: null | 'm2o' | 'o2m' | 'm2m' | 'translations';
hideLabel?: boolean;
hideLoader?: boolean;

View File

@@ -9,7 +9,17 @@
color: var(--foreground-normal);
}
.tox .tox-tbtn svg {
.tox .tox-listbox__select-chevron svg,
.tox .tox-collection__item-caret svg {
fill: var(--foreground-normal);
}
.tox .tox-swatches__picker-btn svg {
fill: var(--foreground-normal);
}
.tox .tox-tbtn svg,
.tox .tox-tbtn:hover svg {
fill: var(--foreground-normal);
}
@@ -97,6 +107,10 @@
left 0 top 0 var(--background-subdued);
}
.tox .tox-pop__dialog .tox-toolbar {
margin-bottom: -2px;
}
body.dark .tox .tox-toolbar,
body.dark .tox .tox-toolbar__primary,
body.dark .tox .tox-toolbar__overflow {
@@ -113,13 +127,34 @@ body.dark .tox .tox-toolbar__overflow {
}
}
.tox .tox-swatches__picker-btn,
.tox .tox-swatches__picker-btn:hover,
.tox .tox-swatches__picker-btn:active,
.tox .tox-split-button:hover {
-webkit-box-shadow: unset;
box-shadow: unset;
}
.tox .tox-tbtn--enabled,
.tox .tox-tbtn--enabled:hover,
.tox .tox-tbtn:hover {
.tox .tox-split-button:hover,
.tox .tox-tbtn:hover,
.tox .tox-split-button:focus {
color: var(--foreground-normal);
background: var(--border-normal);
}
.tox .tox-swatches__picker-btn:hover {
background: transparent;
border: none;
}
.tox .tox-swatch:hover,
.tox .tox-swatch:focus {
-webkit-transform: scale(1.2);
transform: scale(1.2);
}
.mce-content-body {
margin: 20px;
}
@@ -206,20 +241,16 @@ body.dark .tox .tox-toolbar__overflow {
color: var(--foreground-normal);
}
.tox .tox-collection--list .tox-collection__item--enabled,
.tox .tox-collection--list .tox-collection__item--active {
color: var(--foreground-normal) !important;
background-color: var(--background-page) !important;
}
.tox .tox-collection--list .tox-collection__item--enabled {
color: var(--foreground-normal);
background-color: var(--background-page);
background-color: var(--background-normal-alt) !important;
}
.tox .tox-textfield:focus,
.tox .tox-selectfield select:focus,
.tox .tox-textarea:focus {
border-color: var(--foreground-subdued);
border-color: var(--primary);
}
.tox .tox-button {
@@ -314,8 +345,39 @@ body.dark .tox .tox-toolbar__overflow {
background-color: var(--background-normal-alt);
}
.tox .tox-pop__dialog,
.tox:not([dir='rtl']) .tox-toolbar__group:not(:last-of-type),
.tox .tox-collection--list .tox-collection__group {
border-color: var(--border-normal);
}
.tox .tox-insert-table-picker__label {
color: var(--foreground-normal);
}
.tox .tox-insert-table-picker > div {
border-color: var(--border-normal);
}
.tox .tox-insert-table-picker .tox-insert-table-picker__selected {
border-color: var(--primary);
}
.tox .tox-pop.tox-pop--top::after {
border-bottom-color: var(--background-subdued);
}
.tox .tox-pop.tox-pop--top::before {
border-bottom-color: var(--border-normal);
}
.tox .tox-dialog-wrap__backdrop .tox-rgba-preview {
visibility: hidden;
}
@media screen and (max-width: 767px) {
.tox .tox-dialog__body-nav-item {
text-align: center;
}
}
}

View File

@@ -26,6 +26,10 @@
"datetime": {
"datetime": "Datetime",
"description": "Display values related to time",
"format": "Format",
"format_note": "The custom format accetps the __[Date Field Symbol Table](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)__",
"long": "Long",
"short": "Short",
"relative": "Relative",
"relative_label": "Show relative time, eg: 5 minutes ago"
},

View File

@@ -31,6 +31,11 @@
"create_user": "Create User",
"create_webhook": "Create Webhook",
"invite_users": "Invite Users",
"email_examples": "admin@example.com, user@example.com...",
"invite": "Invite",
"emails": "Emails",
"connection_excellent": "Excellent Connection",
"connection_good": "Good Connection",
"connection_fair": "Fair Connection",
@@ -388,8 +393,9 @@
"date-fns_datetime": "PPP h:mma",
"date-fns_date": "PPP",
"date-fns_time": "h:mma",
"date-fns_time": "h:mm:ss a",
"date-fns_date_short": "MMM d, u",
"date-fns_time_short": "h:mma",
"date-fns_date_short_no_year": "MMM d",
"month": "Month",
"year": "Year",
@@ -786,6 +792,7 @@
"create_folder": "Create Folder",
"folder_name": "Folder Name...",
"add_file": "Add File",
"replace_file": "Replace File",
"no_results": "No Results",
"no_results_copy": "Adjust or clear search filters to see results.",

View File

@@ -337,6 +337,7 @@ export default defineComponent({
const fields = [primaryKeyField.value.field];
if (imageSource.value) {
fields.push(`${imageSource.value}.modified_on`);
fields.push(`${imageSource.value}.type`);
fields.push(`${imageSource.value}.filename_disk`);
fields.push(`${imageSource.value}.storage`);
@@ -344,6 +345,7 @@ export default defineComponent({
}
if (props.collection === 'directus_files' && imageSource.value === '$file') {
fields.push('modified_on');
fields.push('type');
}

View File

@@ -38,6 +38,7 @@ type File = {
[key: string]: any;
id: string;
type: string;
modified_on: Date;
};
export default defineComponent({
@@ -101,7 +102,7 @@ export default defineComponent({
key = 'system-medium-contain';
}
return getRootPath() + `assets/${props.file.id}?key=${key}`;
return getRootPath() + `assets/${props.file.id}?key=${key}&modified=${props.file.modified_on}`;
});
const svgSource = computed(() => {
@@ -109,7 +110,7 @@ export default defineComponent({
if (props.file.type.startsWith('image') === false) return null;
if (props.file.type.includes('svg') === false) return null;
return getRootPath() + `assets/${props.file.id}`;
return getRootPath() + `assets/${props.file.id}&modified=${props.file.modified_on}`;
});
const selectionIcon = computed(() => {

View File

@@ -134,7 +134,7 @@
rounded
icon
:loading="saving"
:disabled="saveAllowed === false || hasEdits === false"
:disabled="isSavable === false"
v-tooltip.bottom="saveAllowed ? $t('save') : $t('not_allowed')"
@click="saveAndQuit"
>
@@ -143,7 +143,7 @@
<template #append-outer>
<save-options
v-if="collectionInfo.meta && collectionInfo.meta.singleton !== true"
:disabled="hasEdits === false"
:disabled="isSavable === false"
@save-and-stay="saveAndStay"
@save-and-add-new="saveAndAddNew"
@save-as-copy="saveAsCopyAndNavigate"
@@ -267,7 +267,7 @@ export default defineComponent({
const revisionsDrawerDetail = ref<Vue | null>(null);
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
const { info: collectionInfo, defaults, primaryKeyField } = useCollection(collection);
const {
isNew,
@@ -288,7 +288,24 @@ export default defineComponent({
validationErrors,
} = useItem(collection, primaryKey);
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
const hasEdits = computed(() => Object.keys(edits.value).length > 0);
const isSavable = computed(() => {
if (saveAllowed.value === false) return false;
if (
!primaryKeyField.value?.schema?.has_auto_increment &&
!primaryKeyField.value?.meta?.special?.includes('uuid')
) {
return !!edits.value?.[primaryKeyField.value.field];
}
if (isNew.value === true) {
return Object.keys(defaults.value).length > 0 || hasEdits.value;
}
return hasEdits.value;
});
const confirmDelete = ref(false);
const confirmArchive = ref(false);
@@ -324,9 +341,7 @@ export default defineComponent({
useShortcut('meta+shift+s', saveAndAddNew, form);
const navigationGuard: NavigationGuard = (to, from, next) => {
const hasEdits = Object.keys(edits.value).length > 0;
if (hasEdits) {
if (hasEdits.value) {
confirmLeave.value = true;
leaveTo.value = to.fullPath;
return next(false);
@@ -343,7 +358,7 @@ export default defineComponent({
error,
isNew,
edits,
hasEdits,
isSavable,
saving,
collectionInfo,
saveAndQuit,
@@ -389,14 +404,14 @@ export default defineComponent({
}
async function saveAndQuit() {
if (saveAllowed.value === false || hasEdits.value === false) return;
if (isSavable.value === false) return;
await save();
if (props.singleton === false) router.push(`/collections/${props.collection}`);
}
async function saveAndStay() {
if (saveAllowed.value === false || hasEdits.value === false) return;
if (isSavable.value === false) return;
const savedItem: Record<string, any> = await save();
@@ -410,7 +425,7 @@ export default defineComponent({
}
async function saveAndAddNew() {
if (saveAllowed.value === false || hasEdits.value === false) return;
if (isSavable.value === false) return;
await save();
@@ -470,6 +485,12 @@ export default defineComponent({
return { deleteAllowed, saveAllowed, archiveAllowed, updateAllowed };
}
},
beforeRouteLeave(to, from, next) {
return (this as any).navigationGuard(to, from, next);
},
beforeRouteUpdate(to, from, next) {
return (this as any).navigationGuard(to, from, next);
},
});
</script>

View File

@@ -54,12 +54,9 @@ export default defineComponent({
},
});
htmlString = htmlString.replaceAll(
hintRegex,
(match: string, type: string, title: string, body: string) => {
return `<div class="hint ${type}"><p class="hint-title">${title}</p><p class="hint-body">${body}</p></div>`;
}
);
htmlString = htmlString.replace(hintRegex, (match: string, type: string, title: string, body: string) => {
return `<div class="hint ${type}"><p class="hint-title">${title}</p><p class="hint-body">${body}</p></div>`;
});
html.value = htmlString;
}

View File

@@ -31,14 +31,14 @@ export default defineModule(({ i18n }) => {
for (const doc of directory.children) {
if (doc.type === 'file') {
routes.push({
path: '/' + doc.path.replace('.md', '').replaceAll('\\', '/'),
path: '/' + doc.path.replace('.md', '').replace(/\\/g, '/'),
component: StaticDocs,
});
} else if (doc.type === 'directory') {
if (doc.path && doc.children && doc.children.length > 0)
routes.push({
path: '/' + doc.path.replaceAll('\\', '/'),
redirect: '/' + doc.children![0].path.replace('.md', '').replaceAll('\\', '/'),
path: '/' + doc.path.replace(/\\/g, '/'),
redirect: '/' + doc.children![0].path.replace('.md', '').replace(/\\/g, '/'),
});
routes.push(...parseRoutes(doc));

View File

@@ -64,10 +64,17 @@
</dd>
</div>
<div>
<dt>{{ $t('file') }}</dt>
<dd>
<a :href="`${getRootPath()}assets/${file.id}`" target="_blank">{{ $t('open') }}</a>
</dd>
</div>
<div>
<dt>{{ $t('folder') }}</dt>
<dd>
<button @click="$emit('move-folder')">{{ folder ? folder.name : $t('file_library') }}</button>
<router-link :to="folderLink">{{ folder ? folder.name : $t('file_library') }}</router-link>
</dd>
</div>
@@ -115,6 +122,7 @@ import i18n from '@/lang';
import marked from 'marked';
import localizedFormat from '@/utils/localized-format';
import api from '@/api';
import getRootPath from '@/utils/get-root-path';
import { userName } from '@/utils/user-name';
export default defineComponent({
@@ -140,9 +148,20 @@ export default defineComponent({
const { creationDate, modificationDate } = useDates();
const { userCreated, userModified } = useUser();
const { folder } = useFolder();
const { folder, folderLink } = useFolder();
return { readableMimeType, size, creationDate, modificationDate, userCreated, userModified, folder, marked };
return {
readableMimeType,
size,
creationDate,
modificationDate,
userCreated,
userModified,
folder,
marked,
folderLink,
getRootPath,
};
function useDates() {
const creationDate = ref<string | null>(null);
@@ -230,16 +249,23 @@ export default defineComponent({
function useFolder() {
type Folder = {
id: number;
id: string;
name: string;
};
const loading = ref(false);
const folder = ref<Folder | null>(null);
const folderLink = computed(() => {
if (folder.value === null) {
return `/files`;
}
return `/files/?folder=${folder.value.id}`;
});
watch(() => props.file, fetchFolder, { immediate: true });
return { folder };
return { folder, folderLink };
async function fetchFolder() {
if (!props.file) return null;

View File

@@ -0,0 +1,57 @@
<template>
<v-dialog :active="active" @toggle="$emit('toggle', false)" @esc="$emit('toggle', false)">
<v-card v-if="file">
<v-card-title>{{ $t('replace_file') }}</v-card-title>
<v-card-text>
<v-upload :preset="preset" :file-id="file.id" @input="uploaded" from-url />
</v-card-text>
<v-card-actions>
<v-button secondary @click="$emit('toggle', false)">{{ $t('done') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import api from '@/api';
import router from '@/router';
import { PropType } from 'vue';
export default defineComponent({
model: {
prop: 'active',
event: 'toggle',
},
props: {
active: {
type: Boolean,
default: false,
},
file: {
type: Object,
default: () => ({}),
},
preset: {
type: Object,
default: () => ({}),
},
},
setup(props, { emit }) {
return { uploaded };
function uploaded() {
emit('toggle', false);
emit('replaced');
}
},
});
</script>
<style lang="scss" scoped>
.add-new {
--v-button-background-color: var(--primary-25);
--v-button-color: var(--primary);
--v-button-background-color-hover: var(--primary-50);
--v-button-color-hover: var(--primary);
}
</style>

View File

@@ -119,15 +119,13 @@
:width="item.width"
:height="item.height"
:title="item.title"
@click="previewActive = true"
@click="replaceFileDialogActive = true"
/>
<file-lightbox v-if="item" :id="item.id" v-model="previewActive" />
<image-editor
v-if="item && item.type.startsWith('image')"
:id="item.id"
@refresh="changeCacheBuster"
@refresh="refresh"
v-model="editActive"
/>
@@ -156,7 +154,7 @@
</v-dialog>
<template #sidebar>
<file-info-sidebar-detail :file="item" @move-folder="moveToDialogActive = true" />
<file-info-sidebar-detail :file="item" />
<revisions-drawer-detail
v-if="isBatch === false && isNew === false"
collection="directus_files"
@@ -169,6 +167,8 @@
:primary-key="primaryKey"
/>
</template>
<replace-file v-model="replaceFileDialogActive" @replaced="refresh" :file="item" />
</private-view>
</template>
@@ -184,7 +184,6 @@ import SaveOptions from '@/views/private/components/save-options';
import FilePreview from '@/views/private/components/file-preview';
import ImageEditor from '@/views/private/components/image-editor';
import { nanoid } from 'nanoid';
import FileLightbox from '@/views/private/components/file-lightbox';
import { useFieldsStore } from '@/stores/';
import { Field } from '@/types';
import FileInfoSidebarDetail from '../components/file-info-sidebar-detail.vue';
@@ -194,6 +193,7 @@ import api from '@/api';
import getRootPath from '@/utils/get-root-path';
import FilesNotFound from './not-found.vue';
import useShortcut from '@/composables/use-shortcut';
import ReplaceFile from '../components/replace-file.vue';
type Values = {
[field: string]: any;
@@ -220,10 +220,10 @@ export default defineComponent({
SaveOptions,
FilePreview,
ImageEditor,
FileLightbox,
FileInfoSidebarDetail,
FolderPicker,
FilesNotFound,
ReplaceFile,
},
props: {
primaryKey: {
@@ -236,6 +236,7 @@ export default defineComponent({
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const fieldsStore = useFieldsStore();
const replaceFileDialogActive = ref(false);
const revisionsDrawerDetail = ref<Vue | null>(null);
@@ -256,13 +257,15 @@ export default defineComponent({
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
const confirmDelete = ref(false);
const cacheBuster = ref(nanoid());
const editActive = ref(false);
const previewActive = ref(false);
const fileSrc = computed(() => {
return (
getRootPath() + `assets/${props.primaryKey}?cache-buster=${cacheBuster.value}&key=system-large-contain`
);
if (item.value && item.value.modified_on) {
return (
getRootPath() +
`assets/${props.primaryKey}?cache-buster=${item.value.modified_on}&key=system-large-contain`
);
}
return getRootPath() + `assets/${props.primaryKey}?key=system-large-contain`;
});
// These are the fields that will be prevented from showing up in the form because they'll be shown in the sidebar
@@ -318,10 +321,7 @@ export default defineComponent({
saveAndStay,
saveAsCopyAndNavigate,
isBatch,
changeCacheBuster,
cacheBuster,
editActive,
previewActive,
revisionsDrawerDetail,
formFields,
confirmLeave,
@@ -335,12 +335,10 @@ export default defineComponent({
fileSrc,
form,
to,
replaceFileDialogActive,
refresh,
};
function changeCacheBuster() {
cacheBuster.value = nanoid();
}
function useBreadcrumb() {
const breadcrumb = computed(() => {
if (!item?.value?.folder) {
@@ -405,7 +403,7 @@ export default defineComponent({
const selectedFolder = ref<number | null>();
watch(item, () => {
selectedFolder.value = item.value.folder;
selectedFolder.value = item.value?.folder || null;
});
return { moveToDialogActive, moving, moveToFolder, selectedFolder };

View File

@@ -67,6 +67,7 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
if (inter.system === true) return false;
const matchesType = inter.types.includes(state.fieldData?.type || 'alias');
const matchesLocalType = inter.localTypes?.includes(type);
let matchesRelation = false;
if (type === 'standard' || type === 'presentation') {
@@ -81,7 +82,7 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
matchesRelation = inter.relationship === type;
}
return matchesType && matchesRelation;
return matchesType && matchesRelation && (matchesLocalType === undefined || matchesLocalType);
})
.sort((a, b) => (a.name > b.name ? 1 : -1));
});

View File

@@ -207,6 +207,7 @@ import { getInterfaces } from '@/interfaces';
import router from '@/router';
import notify from '@/utils/notify';
import { i18n } from '@/lang';
import { cloneDeep } from 'lodash';
import { getLocalTypeForField } from '../../get-local-type';
export default defineComponent({
@@ -323,8 +324,8 @@ export default defineComponent({
};
async function saveDuplicate() {
const newField: any = {
...props.field,
const newField: Record<string, any> = {
...cloneDeep(props.field),
field: duplicateName.value,
collection: duplicateTo.value,
};
@@ -334,6 +335,10 @@ export default defineComponent({
delete newField.meta.sort;
}
if (newField.schema) {
delete newField.schema.comment;
}
delete newField.name;
duplicating.value = true;

View File

@@ -304,7 +304,6 @@ export default defineComponent({
type: 'string',
meta: {
width: 'full',
required: true,
options: {
choices: [
{
@@ -342,6 +341,7 @@ export default defineComponent({
},
schema: {
default_value: 'draft',
is_nullable: false,
},
});

View File

@@ -39,6 +39,10 @@
</v-card>
</v-dialog>
<v-button rounded icon @click="userInviteModalActive = true" v-tooltip.bottom="$t('invite_users')">
<v-icon name="person_add" />
</v-button>
<v-button
rounded
icon
@@ -55,6 +59,8 @@
<settings-navigation />
</template>
<users-invite v-model="userInviteModalActive" :role="primaryKey" />
<div class="roles">
<v-notice v-if="adminEnabled" type="info">
{{ $t('admins_have_all_permissions') }}
@@ -85,9 +91,11 @@ import SettingsNavigation from '../../../components/navigation.vue';
import router from '@/router';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import useItem from '@/composables/use-item';
import { useUserStore } from '@/stores/';
import { useUserStore, usePermissionsStore } from '@/stores/';
import RoleInfoSidebarDetail from './components/role-info-sidebar-detail.vue';
import PermissionsOverview from './components/permissions-overview.vue';
import UsersInvite from '@/views/private/components/users-invite';
import usersCreate from '../../../../../../../api/dist/cli/commands/users/create';
type Values = {
[field: string]: any;
@@ -95,7 +103,7 @@ type Values = {
export default defineComponent({
name: 'roles-item',
components: { SettingsNavigation, RevisionsDrawerDetail, RoleInfoSidebarDetail, PermissionsOverview },
components: { SettingsNavigation, RevisionsDrawerDetail, RoleInfoSidebarDetail, PermissionsOverview, UsersInvite },
props: {
primaryKey: {
type: String,
@@ -108,7 +116,8 @@ export default defineComponent({
},
setup(props) {
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const userInviteModalActive = ref(false);
const { primaryKey } = toRefs(props);
const { edits, item, saving, loading, error, save, remove, deleting, isBatch } = useItem(
@@ -124,7 +133,7 @@ export default defineComponent({
const values = {
...item.value,
...edits.value,
};
} as Record<string, any>;
return !!values.admin_access;
});
@@ -142,6 +151,7 @@ export default defineComponent({
deleting,
isBatch,
adminEnabled,
userInviteModalActive,
};
/**

View File

@@ -115,7 +115,7 @@ export default defineComponent({
const title = computed(() => {
if (loading.value) return i18n.t('loading');
if (isNew.value) return i18n.t('creating_webhook');
return item.value.name;
return item.value?.name;
});
return {

View File

@@ -49,6 +49,16 @@
<v-icon name="edit" outline />
</v-button>
<v-button
v-if="canInviteUsers"
rounded
icon
@click="userInviteModalActive = true"
v-tooltip.bottom="$t('invite_users')"
>
<v-icon name="person_add" />
</v-button>
<v-button rounded icon :to="addNewLink" v-tooltip.bottom="$t('create_user')">
<v-icon name="add" />
</v-button>
@@ -58,6 +68,8 @@
<users-navigation :current-role="queryFilters && queryFilters.role" />
</template>
<users-invite v-if="canInviteUsers" v-model="userInviteModalActive" />
<component
class="layout"
ref="layoutRef"
@@ -104,6 +116,7 @@
<script lang="ts">
import { defineComponent, computed, ref, PropType } from '@vue/composition-api';
import UsersNavigation from '../components/navigation.vue';
import UsersInvite from '@/views/private/components/users-invite';
import { i18n } from '@/lang';
import api from '@/api';
@@ -111,6 +124,7 @@ import { LayoutComponent } from '@/layouts/types';
import usePreset from '@/composables/use-preset';
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
import SearchInput from '@/views/private/components/search-input';
import { useUserStore, usePermissionsStore } from '@/stores';
import marked from 'marked';
import useNavigation from '../composables/use-navigation';
@@ -120,7 +134,7 @@ type Item = {
export default defineComponent({
name: 'users-collection',
components: { UsersNavigation, LayoutSidebarDetail, SearchInput },
components: { UsersNavigation, LayoutSidebarDetail, SearchInput, UsersInvite },
props: {
queryFilters: {
type: Object as PropType<Record<string, string>>,
@@ -130,6 +144,9 @@ export default defineComponent({
setup(props) {
const { roles } = useNavigation();
const layoutRef = ref<LayoutComponent | null>(null);
const userInviteModalActive = ref(false);
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const selection = ref<Item[]>([]);
@@ -157,7 +174,23 @@ export default defineComponent({
return filters.value;
});
const canInviteUsers = computed(() => {
const isAdmin = !!userStore.state.currentUser?.role?.admin_access;
if (isAdmin) return true;
const usersCreatePermission = permissionsStore.state.permissions.find(
(permission) => permission.collection === 'directus_users' && permission.action === 'create'
);
const rolesReadPermission = permissionsStore.state.permissions.find(
(permission) => permission.collection === 'directus_roles' && permission.action === 'read'
);
return !!usersCreatePermission && !!rolesReadPermission;
});
return {
canInviteUsers,
_filters,
addNewLink,
batchDelete,
@@ -175,6 +208,7 @@ export default defineComponent({
searchQuery,
marked,
clearFilters,
userInviteModalActive,
};
function useBatchDelete() {

View File

@@ -384,7 +384,7 @@ export default defineComponent({
}
async function refreshCurrentUser() {
if (userStore.state.currentUser!.id === item.value.id) {
if (userStore.state.currentUser!.id === item.value?.id) {
await userStore.hydrate();
}
}

View File

@@ -27,7 +27,6 @@ const fakeFilesField: Field = {
display_options: null,
hidden: false,
locked: true,
required: false,
translations: null,
readonly: true,
width: 'full',

View File

@@ -12,7 +12,7 @@
}
.type-label {
margin-bottom: 4px;
margin-bottom: 8px;
}
// full by default

View File

@@ -60,7 +60,6 @@ export type FieldMeta = {
options: null | Record<string, any>;
display_options: null | Record<string, any>;
readonly: boolean;
required: boolean;
sort: number | null;
special: string[] | null;
translations: null | Translations[];

View File

@@ -2,7 +2,7 @@ import { usePermissionsStore, useUserStore } from '@/stores';
import { Permission } from '@/types';
import generateJoi from '@/utils/generate-joi';
export function isAllowed(collection: string, action: Permission['action'], value: Record<string, any>) {
export function isAllowed(collection: string, action: Permission['action'], value: Record<string, any> | null) {
const permissionsStore = usePermissionsStore();
const userStore = useUserStore();

View File

@@ -11,6 +11,7 @@ export default async function uploadFile(
onProgressChange?: (percentage: number) => void;
notifications?: boolean;
preset?: Record<string, any>;
fileId?: string;
}
) {
const progressHandler = options?.onProgressChange || (() => undefined);
@@ -25,9 +26,17 @@ export default async function uploadFile(
formData.append('file', file);
try {
const response = await api.post(`/files`, formData, {
onUploadProgress,
});
let response = null;
if (options?.fileId) {
response = await api.patch(`/files/${options.fileId}`, formData, {
onUploadProgress,
});
} else {
response = await api.post(`/files`, formData, {
onUploadProgress,
});
}
if (options?.notifications) {
notify({

View File

@@ -1,5 +1,13 @@
<template>
<v-drawer v-model="_active" :title="$t('select_item')" @cancel="cancel">
<template #actions>
<search-input v-model="searchQuery" />
<v-button @click="save" icon rounded v-tooltip.bottom="$t('save')">
<v-icon name="check" />
</v-button>
</template>
<component
:is="`layout-${localLayout}`"
:collection="collection"
@@ -7,6 +15,7 @@
:filters="filters"
:layout-query.sync="localQuery"
:layout-options.sync="localOptions"
:search-query="searchQuery"
@update:selection="onSelect"
select-mode
class="layout"
@@ -19,12 +28,6 @@
<v-info :title="$tc('item_count', 0)" :icon="collectionInfo.icon" center />
</template>
</component>
<template #actions>
<v-button @click="save" icon rounded v-tooltip.bottom="$t('save')">
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
</template>
@@ -33,8 +36,10 @@ import { defineComponent, PropType, ref, computed, toRefs, onUnmounted } from '@
import { Filter } from '@/types';
import usePreset from '@/composables/use-preset';
import useCollection from '@/composables/use-collection';
import SearchInput from '@/views/private/components/search-input';
export default defineComponent({
components: { SearchInput },
props: {
active: {
type: Boolean,
@@ -65,7 +70,7 @@ export default defineComponent({
const { collection } = toRefs(props);
const { info: collectionInfo } = useCollection(collection);
const { layout, layoutOptions, layoutQuery } = usePreset(collection);
const { layout, layoutOptions, layoutQuery, searchQuery } = usePreset(collection);
// This is a local copy of the layout. This means that we can sync it the layout without
// having use-preset auto-save the values
@@ -73,7 +78,18 @@ export default defineComponent({
const localOptions = ref(layoutOptions.value);
const localQuery = ref(layoutQuery.value);
return { save, cancel, _active, _selection, onSelect, localLayout, localOptions, localQuery, collectionInfo };
return {
save,
cancel,
_active,
_selection,
onSelect,
localLayout,
localOptions,
localQuery,
collectionInfo,
searchQuery,
};
function useActiveState() {
const localActive = ref(false);

View File

@@ -1,16 +1,21 @@
<template>
<div class="file-preview" v-if="type">
<div v-if="type === 'image'" class="image" :class="{ svg: isSVG, 'max-size': inModal === false }" @click="$emit('click')">
<div
v-if="type === 'image'"
class="image"
:class="{ svg: isSVG, 'max-size': inModal === false }"
@click="$emit('click')"
>
<img
:src="src"
:width="width"
:height="height"
:style="{
'maxWidth': width ? width + 'px' : '100%'
maxWidth: width ? width + 'px' : '100%',
}"
:alt="title"
/>
<v-icon v-if="inModal === false" name="fullscreen" />
<v-icon v-if="inModal === false" name="upload" />
</div>
<video v-else-if="type === 'video'" controls :src="src" />
@@ -106,9 +111,9 @@ audio {
}
img {
max-height: inherit;
z-index: 1;
display: block;
max-height: inherit;
margin: 0 auto;
}

View File

@@ -0,0 +1,4 @@
import UsersInvite from './users-invite.vue';
export { UsersInvite };
export default UsersInvite;

View File

@@ -0,0 +1,121 @@
<template>
<v-dialog :active="active" @toggle="$emit('toggle', $event)" @esc="$emit('toggle', false)">
<v-card>
<v-card-title>{{ $t('invite_users') }}</v-card-title>
<v-card-text class="grid">
<div class="field">
<div class="type-label">{{ $t('emails') }}</div>
<interface-tags
v-model="emails"
:placeholder="$t('email_examples')"
icon-right="email"
whitespace=""
/>
</div>
<div class="field" v-if="role === null">
<div class="type-label">{{ $t('role') }}</div>
<v-select v-model="roleSelected" :items="roles" />
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="$emit('toggle', false)">{{ $t('cancel') }}</v-button>
<v-button @click="inviteUsers" :disabled="emails === null || emails.length === 0" :loading="loading">
{{ $t('invite') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, computed, ref, PropType, watch } from '@vue/composition-api';
import api from '@/api';
import { useNotificationsStore } from '@/stores';
import i18n from '@/lang';
export default defineComponent({
model: {
prop: 'active',
event: 'toggle',
},
props: {
active: {
type: Boolean,
default: false,
},
role: {
type: String,
default: null,
},
},
setup(props, { emit }) {
const notifications = useNotificationsStore();
const emails = ref<string[]>([]);
const roles = ref<Record<string, any>[]>([]);
const roleSelected = ref<string | null>(props.role);
const loading = ref(false);
watch(
() => props.active,
() => {
loadRoles();
}
);
return { emails, inviteUsers, roles, roleSelected, loading };
async function inviteUsers() {
loading.value = true;
try {
await Promise.all(
emails.value.map((email) => {
return api.post('/users/invite', {
email,
role: roleSelected.value,
});
})
);
emit('toggle', false);
} catch (err) {
notifications.add({
title: i18n.t('server_error'),
text: err.message,
persist: true,
type: 'error',
});
} finally {
loading.value = false;
}
}
async function loadRoles() {
const response = await api.get('/roles');
roles.value = response.data.data.map((role: Record<string, any>) => ({
text: role.name,
value: role.id,
}));
if (roles.value.length > 0 && roleSelected.value === null) {
roleSelected.value = roles.value[0].value;
}
}
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.v-card-text {
--v-form-vertical-gap: 20px;
@include form-grid;
}
.v-card-title {
font-size: 20px;
}
</style>

View File

@@ -148,7 +148,7 @@ module.exports = function registerHook({ services, exceptions }) {
return {
// Force everything to be admin-only at all times
'items.*.*': async function({ item, accountability }) {
'items.*': async function({ item, accountability }) {
if (accountability.admin !== true) throw new ForbiddenException();
},
// Sync with external recipes service, cancel creation on failure

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/docs",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@directus/docs",
"private": false,
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -5,7 +5,7 @@
"docs",
"packages/*"
],
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"command": {
"bootstrap": {
"npmClientArgs": [

148
package-lock.json generated
View File

@@ -8216,13 +8216,6 @@
"supports-color": "^5.3.0"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"optional": true
},
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -8260,83 +8253,6 @@
"worker-rpc": "^0.1.0"
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz",
"integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==",
"dev": true,
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"optional": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"optional": true,
"requires": {
"color-name": "~1.1.4"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"optional": true
},
"semver": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
"integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==",
"dev": true,
"optional": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"optional": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"globby": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
@@ -8382,18 +8298,6 @@
}
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"dev": true,
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@@ -16008,6 +15912,7 @@
"lodash": "^4.17.19",
"macos-release": "^2.4.1",
"memcached": "^2.2.2",
"mime-types": "^2.1.27",
"ms": "^2.1.2",
"mssql": "^6.2.0",
"mysql": "^2.18.1",
@@ -16021,6 +15926,7 @@
"pg": "^8.4.1",
"pino": "^6.4.1",
"pino-colada": "^2.1.0",
"qs": "^6.9.4",
"rate-limiter-flexible": "^2.1.10",
"resolve-cwd": "^3.0.0",
"sharp": "^0.25.4",
@@ -16083,6 +15989,11 @@
"integrity": "sha512-64/bYByMrhWULUaCd+6/72c9PMWhiVFs3EVxl9Ct6a3v/U8+rKgqP2w+kKg/BIGgMJyB+Bk/eNivT32Al+Jghw==",
"optional": true
},
"qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ=="
},
"uuid": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
@@ -18575,6 +18486,51 @@
}
}
},
"fork-ts-checker-webpack-plugin-v5": {
"version": "npm:fork-ts-checker-webpack-plugin@5.2.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz",
"integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==",
"dev": true,
"optional": true,
"requires": {
"@babel/code-frame": "^7.8.3",
"@types/json-schema": "^7.0.5",
"chalk": "^4.1.0",
"cosmiconfig": "^6.0.0",
"deepmerge": "^4.2.2",
"fs-extra": "^9.0.0",
"memfs": "^3.1.2",
"minimatch": "^3.0.4",
"schema-utils": "2.7.0",
"semver": "^7.3.2",
"tapable": "^1.0.0"
},
"dependencies": {
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"optional": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"schema-utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
"dev": true,
"optional": true,
"requires": {
"@types/json-schema": "^7.0.4",
"ajv": "^6.12.2",
"ajv-keywords": "^3.4.1"
}
}
}
},
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "create-directus-project",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-directus-project",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"description": "A small installer util that will create a directory, add boilerplate folders, and install Directus through npm.",
"main": "lib/index.js",
"bin": "./lib/index.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/format-title",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/format-title",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"description": "Custom string formatter that converts any string into [Title Case](http://www.grammar-monster.com/lessons/capital_letters_title_case.htm)",
"keywords": [
"title-case",

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/specs",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/specs",
"version": "9.0.0-rc.1",
"version": "9.0.0-rc.2",
"description": "OpenAPI Specification of the Directus API",
"main": "index.js",
"scripts": {