Start on data model settings (#258)

* Use menu for project switcher

* Setup base structure for settings module

* Setup routes for settings

* Tweak v-menu styling

* Rough in collection overview in settings

* Save field info based on sort

* Add accidentally renamed global route

* Add move-in-arrow util

* Add update methods for fields

* Add field sorting logic

* Handle sorting between groups

* Add support for label on the v-divider component

* Register missing components

* Allow multiple dialogs at once

* Progress in settings

* Fix full-width option of input

* Update missing translations

* Improve menu performance

* Add field sizing

* Add disabled state to list item

* Add visibility toggle

* Undo changes on API errors

* Add test for usecollectoins

* Add notifications to field updates

* Fix linter warning

* Remove useCollection directive

* Fix linter warnings
This commit is contained in:
Rijk van Zanten
2020-03-30 13:39:45 -04:00
committed by GitHub
parent 7c41727e0d
commit 6486dd810e
48 changed files with 1248 additions and 94 deletions

View File

@@ -6,7 +6,7 @@
</div>
</transition>
<router-view v-if="!hydrating" />
<portal-target name="dialog-outlet" />
<portal-target name="dialog-outlet" multiple />
</div>
</template>

View File

@@ -7,6 +7,7 @@ import VCard, { VCardActions, VCardTitle, VCardSubtitle, VCardText } from './v-c
import VCheckbox from './v-checkbox/';
import VChip from './v-chip/';
import VDialog from './v-dialog';
import VDivider from './v-divider';
import VForm from './v-form';
import VHover from './v-hover/';
import VIcon from './v-icon/';
@@ -20,6 +21,7 @@ import VList, {
VListItemTitle,
VListGroup,
} from './v-list/';
import VMenu from './v-menu/';
import VNotice from './v-notice/';
import VOverlay from './v-overlay/';
import VPagination from './v-pagination/';
@@ -42,6 +44,7 @@ Vue.component('v-card-actions', VCardActions);
Vue.component('v-checkbox', VCheckbox);
Vue.component('v-chip', VChip);
Vue.component('v-dialog', VDialog);
Vue.component('v-divider', VDivider);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);
@@ -55,6 +58,7 @@ Vue.component('v-list-item-icon', VListItemIcon);
Vue.component('v-list-item-subtitle', VListItemSubtitle);
Vue.component('v-list-item-title', VListItemTitle);
Vue.component('v-list-group', VListGroup);
Vue.component('v-menu', VMenu);
Vue.component('v-notice', VNotice);
Vue.component('v-overlay', VOverlay);
Vue.component('v-pagination', VPagination);

View File

@@ -18,7 +18,10 @@ Divides content. Made to be used in `v-list` or `v-tabs` components.
n/a
## Slots
n/a
| Slot | Description | Data |
|-----------|-------------------------------------------------------------|------|
| _default_ | Label on the divider. This isn't rendered in vertical mode. | |
## CSS Variables
| Variable | Default |

View File

@@ -35,6 +35,24 @@ export const basic = () =>
`,
});
export const withText = () =>
defineComponent({
components: { VDivider },
props: {
vertical: {
default: boolean('Vertical', false),
},
inset: {
default: boolean('Inset', false),
},
},
template: `
<v-divider :vertical="vertical" :inset="inset">
This is a divider.
</v-divider>
`,
});
export const inList = () =>
defineComponent({
components: {

View File

@@ -1,10 +1,8 @@
<template>
<hr
class="v-divider"
role="separator"
:class="{ vertical, inset }"
:aria-orientation="vertical ? 'vertical' : 'horizontal'"
/>
<div class="v-divider" :class="{ vertical, inset }">
<span v-if="!vertical && $slots.default"><slot /></span>
<hr role="separator" :aria-orientation="vertical ? 'vertical' : 'horizontal'" />
</div>
</template>
<script lang="ts">
@@ -26,17 +24,29 @@ export default defineComponent({
<style lang="scss" scoped>
.v-divider {
--v-divider-color: var(--foreground-color-tertiary);
--v-divider-color: var(--input-border-color);
--v-divider-label-color: var(--input-action-color);
display: block;
display: flex;
flex-basis: 0px;
flex-grow: 1;
flex-shrink: 1;
max-width: 100%;
align-items: center;
overflow: visible;
border: solid;
border-color: var(--v-divider-color);
border-width: 2px 0 0 0;
hr {
flex-grow: 1;
max-width: 100%;
border: solid;
border-color: var(--v-divider-color);
border-width: 2px 0 0 0;
}
span {
margin-right: 16px;
color: var(--v-divider-label-color);
font-size: 16px;
}
&.inset:not(.vertical) {
max-width: calc(100% - 52px);
@@ -46,15 +56,20 @@ export default defineComponent({
&.vertical {
display: inline-flex;
align-self: stretch;
width: 0px;
max-width: 0px;
height: inherit;
border-width: 0 2px 0 0;
hr {
width: 0px;
max-width: 0px;
height: inherit;
border-width: 0 2px 0 0;
}
&.inset {
min-height: 0;
max-height: calc(100% - 16px);
margin-top: 8px;
hr {
min-height: 0;
max-height: calc(100% - 16px);
margin-top: 8px;
}
}
}
}

View File

@@ -106,7 +106,7 @@ export default defineComponent({
}
svg {
display: block;
display: inline-block;
color: inherit;
fill: currentColor;
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="v-input">
<div class="v-input" :class="{ 'full-width': fullWidth }">
<div v-if="$slots['prepend-outer']" class="prepend-outer">
<slot name="prepend-outer" :value="value" :disabled="disabled" />
</div>
<div class="input" :class="{ disabled, monospace, 'full-width': fullWidth }">
<div class="input" :class="{ disabled, monospace }">
<div v-if="$slots.prepend" class="prepend">
<slot name="prepend" :value="value" :disabled="disabled" />
</div>
@@ -102,13 +102,13 @@ export default defineComponent({
margin-right: 8px;
}
&:not(.disabled):hover {
&:hover {
color: var(--input-foreground-color-hover);
background-color: var(--input-background-color-hover);
border-color: var(--input-border-color-hover);
}
&:not(.disabled):focus-within {
&:focus-within {
color: var(--input-foreground-color-focus);
background-color: var(--input-background-color-focus);
border-color: var(--input-border-color-focus);
@@ -120,10 +120,6 @@ export default defineComponent({
border-color: var(--input-border-color-disabled);
}
&.full-width {
width: 100%;
}
input {
flex-grow: 1;
height: 100%;
@@ -155,5 +151,13 @@ export default defineComponent({
.append-outer {
margin-left: 8px;
}
&.full-width {
width: 100%;
.input {
width: 100%;
}
}
}
</style>

View File

@@ -61,11 +61,12 @@ A wrapper for list items that formats children nicely. Can be used on its own or
## Props
| Prop | Description | Default |
| ------- | -------------------------------------------------------------------- | ------- |
| `dense` | Removes some padding to make the individual list item shorter | `false` |
| `lines` | Sets if the list item will support `1`, `2`, or `3` lines of content | `null` |
| `to` | Render as vue router-link with to link | `null` |
| Prop | Description | Default |
|------------|----------------------------------------------------------------------|---------|
| `dense` | Removes some padding to make the individual list item shorter | `false` |
| `lines` | Sets if the list item will support `1`, `2`, or `3` lines of content | `null` |
| `to` | Render as vue router-link with to link | `null` |
| `disabled` | Disable the list item | `false` |
## Events

View File

@@ -10,8 +10,9 @@
'three-line': lines === 3,
'two-line': lines === 2,
'one-line': lines === 1,
disabled,
}"
v-on="$listeners"
v-on="disabled === false && $listeners"
>
<slot></slot>
</component>
@@ -35,6 +36,10 @@ export default defineComponent({
type: [String, Object] as PropType<string | Location>,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { listeners }) {
const component = computed<string>(() => (props.to ? 'router-link' : 'li'));
@@ -98,17 +103,22 @@ export default defineComponent({
transition-property: background-color, color;
user-select: none;
&:hover {
&:not(.disabled):hover {
color: var(--v-list-item-color-hover);
background-color: var(--v-list-item-background-color-hover);
}
&:active,
&:not(.disabled):active,
&.active {
color: var(--v-list-item-color-active);
background-color: var(--v-list-item-background-color-active);
}
}
&.disabled {
--v-list-item-color: var(--foreground-color-secondary);
}
@at-root {
.v-list,
#{$this},

View File

@@ -2,14 +2,13 @@ import { createPopper } from '@popperjs/core/lib/popper-base';
import popperOffsets from '@popperjs/core/lib/modifiers/popperOffsets';
import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow';
import computeStyles from '@popperjs/core/lib/modifiers/computeStyles';
import applyStyles from '@popperjs/core/lib/modifiers/applyStyles';
import eventListeners from '@popperjs/core/lib/modifiers/eventListeners';
import arrow from '@popperjs/core/lib/modifiers/arrow';
import flip from '@popperjs/core/lib/modifiers/flip';
import offset from '@popperjs/core/lib/modifiers/offset';
import { Instance, Placement, Modifier } from '@popperjs/core';
import { onMounted, onUnmounted, ref, Ref, watch } from '@vue/composition-api';
import { onUnmounted, ref, Ref, watch } from '@vue/composition-api';
export function usePopper(
reference: Ref<HTMLElement | null>,
@@ -17,20 +16,13 @@ export function usePopper(
options: Readonly<Ref<Readonly<{ placement: Placement; attached: boolean; arrow: boolean }>>>
) {
const popperInstance = ref<Instance>(null);
const styles = ref({});
// The internal placement can change based on the flip / overflow modifiers
const placement = ref(options.value.placement);
onMounted(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
popperInstance.value = createPopper(reference.value!, popper.value!, {
placement: options.value.attached ? 'bottom-start' : options.value.placement,
modifiers: getModifiers(),
});
});
onUnmounted(() => {
popperInstance.value?.destroy();
stop();
});
watch(options, () => {
@@ -40,9 +32,23 @@ export function usePopper(
});
});
return { popperInstance, placement };
return { popperInstance, placement, start, stop, styles };
function getModifiers() {
function start() {
return new Promise((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
popperInstance.value = createPopper(reference.value!, popper.value!, {
placement: options.value.attached ? 'bottom-start' : options.value.placement,
modifiers: getModifiers(resolve),
});
});
}
function stop() {
popperInstance.value?.destroy();
}
function getModifiers(callback: () => void = () => undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modifiers: Partial<Modifier<any>>[] = [
popperOffsets,
@@ -54,7 +60,6 @@ export function usePopper(
},
},
computeStyles,
applyStyles,
flip,
eventListeners,
{
@@ -65,6 +70,15 @@ export function usePopper(
placement.value = state.placement;
},
},
{
name: 'applyStyles',
enabled: true,
phase: 'write',
fn({ state }) {
styles.value = state.styles.popper;
callback();
},
},
];
if (options.value.arrow === true) {

View File

@@ -3,14 +3,20 @@
class="v-menu"
v-click-outside="{
handler: deactivate,
disabled: closeOnClick === false,
disabled: isActive === false || closeOnClick === false,
}"
>
<div ref="activator" class="v-menu-activator">
<slot name="activator" v-bind="{ toggle: toggle, active: isActive }" />
</div>
<div ref="popper" class="v-menu-popper">
<div
ref="popper"
class="v-menu-popper"
:class="{ active: isActive }"
:data-placement="popperPlacement"
:style="styles"
>
<div v-show="showArrow" class="arrow" :class="{ active: isActive }" data-popper-arrow />
<div :class="{ active: isActive }" class="v-menu-content" @click="onContentClick">
<slot />
@@ -59,7 +65,7 @@ export default defineComponent({
return (activator.value as HTMLElement)?.childNodes[0] as HTMLElement;
});
const { placement: popperPlacement } = usePopper(
const { start, stop, styles, placement: popperPlacement } = usePopper(
reference,
popper,
computed(() => ({
@@ -77,9 +83,10 @@ export default defineComponent({
popper,
isActive,
toggle,
popperPlacement,
deactivate,
onContentClick,
styles,
popperPlacement,
};
function useActiveState() {
@@ -92,7 +99,13 @@ export default defineComponent({
return localIsActive.value;
},
set(newActive) {
async set(newActive) {
if (newActive === true) {
await start();
} else {
stop();
}
emit('input', newActive);
localIsActive.value = newActive;
},
@@ -132,6 +145,14 @@ export default defineComponent({
}
.v-menu-popper {
position: absolute;
z-index: 5;
pointer-events: none;
&.active {
pointer-events: all;
}
.arrow,
.arrow::before {
position: absolute;
@@ -155,20 +176,20 @@ export default defineComponent({
}
}
&[data-popper-placement^='top'] .arrow {
&[data-placement^='top'] .arrow {
bottom: -4px;
}
&[data-popper-placement^='bottom'] .arrow {
&[data-placement^='bottom'] .arrow {
top: -4px;
}
&[data-popper-placement^='right'] .arrow {
&[data-placement^='right'] .arrow {
left: -4px;
}
&[data-popper-placement^='left'] .arrow {
left: -4px;
&[data-placement^='left'] .arrow {
right: -4px;
}
.v-menu-content {
@@ -177,11 +198,11 @@ export default defineComponent({
background-color: var(--highlight);
border: 2px solid var(--input-border-color);
border-radius: var(--input-border-radius);
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
opacity: 0;
transition-timing-function: var(--transition-out);
transition-duration: var(--fast);
transition-property: opacity, transform;
pointer-events: none;
contain: content;
.v-list {
@@ -189,62 +210,62 @@ export default defineComponent({
}
}
&[data-popper-placement='top'] .v-menu-content {
&[data-placement='top'] .v-menu-content {
transform: scaleY(0.8);
transform-origin: bottom center;
}
&[data-popper-placement='top-start'] .v-menu-content {
&[data-placement='top-start'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: bottom left;
}
&[data-popper-placement='top-end'] .v-menu-content {
&[data-placement='top-end'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: bottom right;
}
&[data-popper-placement='right'] .v-menu-content {
&[data-placement='right'] .v-menu-content {
transform: scaleX(0.8);
transform-origin: center left;
}
&[data-popper-placement='right-start'] .v-menu-content {
&[data-placement='right-start'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: top left;
}
&[data-popper-placement='right-end'] .v-menu-content {
&[data-placement='right-end'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: bottom left;
}
&[data-popper-placement='bottom'] .v-menu-content {
&[data-placement='bottom'] .v-menu-content {
transform: scaleY(0.8);
transform-origin: top center;
}
&[data-popper-placement='bottom-start'] .v-menu-content {
&[data-placement='bottom-start'] .v-menu-content {
transform: scaleY(0.8);
transform-origin: top left;
}
&[data-popper-placement='bottom-end'] .v-menu-content {
&[data-placement='bottom-end'] .v-menu-content {
transform: scaleY(0.8);
transform-origin: top right;
}
&[data-popper-placement='left'] .v-menu-content {
&[data-placement='left'] .v-menu-content {
transform: scaleX(0.8);
transform-origin: center right;
}
&[data-popper-placement='left-start'] .v-menu-content {
&[data-placement='left-start'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: top right;
}
&[data-popper-placement='left-end'] .v-menu-content {
&[data-placement='left-end'] .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
transform-origin: bottom right;
}
@@ -254,7 +275,6 @@ export default defineComponent({
opacity: 1;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1.5);
transition-duration: var(--medium);
pointer-events: all;
}
}
</style>

View File

@@ -0,0 +1,4 @@
import { useCollection } from './use-collection';
export { useCollection };
export default useCollection;

View File

@@ -0,0 +1,79 @@
import { useCollection } from './use-collection';
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections/';
import useFieldsStore from '@/stores/fields/';
describe('Compositions / useCollection', () => {
let req: any = {};
beforeAll(() => {
Vue.use(VueCompositionAPI);
});
beforeEach(() => {
req = {};
});
it('Gets the collection info from the collections store', () => {
useCollectionsStore(req).state.collections = [
{
collection: 'files',
test: true,
},
{
collection: 'another-collection',
test: false,
},
] as any;
useFieldsStore(req).state.fields = [
{
collection: 'files',
field: 'id',
primary_key: true,
test: true,
},
{
collection: 'another-collection',
field: 'id',
primary_key: true,
test: false,
},
{
collection: 'files',
field: 'title',
primary_key: false,
test: true,
},
] as any;
const { info, fields, primaryKeyField } = useCollection('files');
expect(info.value).toEqual({
collection: 'files',
test: true,
});
expect(fields.value).toEqual([
{
collection: 'files',
field: 'id',
primary_key: true,
test: true,
},
{
collection: 'files',
field: 'title',
primary_key: false,
test: true,
},
]);
expect(primaryKeyField.value).toEqual({
collection: 'files',
field: 'id',
primary_key: true,
test: true,
});
});
});

View File

@@ -0,0 +1,24 @@
import { computed } from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections';
import useFieldsStore from '@/stores/fields';
export function useCollection(collection: string) {
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const info = computed(() => {
return collectionsStore.state.collections.find(({ collection: key }) => key === collection);
});
const fields = computed(() => {
return fieldsStore.state.fields.filter((field) => field.collection === collection);
});
const primaryKeyField = computed(() => {
return fields.value?.find(
(field) => field.collection === collection && field.primary_key === true
);
});
return { info, fields, primaryKeyField };
}

View File

@@ -1,6 +1,7 @@
import VueI18n from 'vue-i18n';
import { Component } from 'vue';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type DisplayHandlerFunction = (value: any) => string | null;
export type DisplayConfig = {

View File

@@ -1,4 +1,22 @@
{
"edit_field": "Edit Field",
"duplicate_field": "Duplicate Field",
"half_width": "Half Width",
"full_width": "Full Width",
"fill_width": "Fill Width",
"hide_field_on_detail": "Hide Field on Detail",
"show_field_on_detail": "Show Field on Detail",
"delete_field": "Delete Field",
"fields_and_layout": "Fields and Layout",
"field_create_success": "Field '{field}' created",
"field_update_success": "Field '{field}' updated",
"field_delete_success": "Field '{field}' deleted",
"field_create_failure": "Could not create '{field}'",
"field_update_failure": "Could not update '{field}'",
"field_delete_failure": "Could not delete '{field}'",
"fields_update_success": "Fields updated",
"fields_update_failure": "Could not update fields",
"about_directus": "About Directus",
"activity": "Activity",
"activity_log": "Activity Log",
@@ -476,6 +494,7 @@
"server_trouble": "Server Trouble",
"server_trouble_copy": "Try again later or contact your system administrator help.",
"settings": "Settings",
"settings_data_model": "Data Model",
"settings_collections_fields": "Collections & Fields",
"settings_extensions": "Extensions",
"settings_global": "Global Settings",

View File

@@ -1,7 +1,9 @@
import { i18n } from '@/lang/';
import { ModuleDefineParam, ModuleContext, ModuleConfig } from './types';
export function defineModule(config: ModuleDefineParam): ModuleConfig {
export function defineModule(
config: ModuleDefineParam | ((context: ModuleContext) => ModuleConfig)
): ModuleConfig {
let options: ModuleConfig;
if (typeof config === 'function') {
@@ -11,10 +13,17 @@ export function defineModule(config: ModuleDefineParam): ModuleConfig {
options = config;
}
options.routes = options.routes.map((route) => ({
...route,
path: `/:project/${options.id}${route.path}`,
}));
options.routes = options.routes.map((route) => {
if (route.path) {
route.path = `/:project/${options.id}${route.path}`;
}
if (route.redirect) {
route.redirect = `/:project/${options.id}${route.redirect}`;
}
return route;
});
return options;
}

View File

@@ -1,3 +1,4 @@
import CollectionsModule from './collections/';
export const modules = [CollectionsModule];
import SettingsModule from './settings/';
export const modules = [CollectionsModule, SettingsModule];
export default modules;

View File

@@ -0,0 +1,4 @@
import SettingsNavigation from './navigation.vue';
export { SettingsNavigation };
export default SettingsNavigation;

View File

@@ -0,0 +1,48 @@
<template>
<v-list nav>
<v-list-item v-for="item in navItems" :to="item.to" :key="item.to">
<v-list-item-icon><v-icon :name="item.icon" /></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<script lang="ts">
import { defineComponent, toRefs } from '@vue/composition-api';
import { i18n } from '@/lang';
import { useProjectsStore } from '@/stores/projects';
export default defineComponent({
setup() {
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const navItems = [
{
icon: 'public',
name: i18n.t('settings_global'),
to: `/${currentProjectKey.value}/settings/global`,
},
{
icon: 'account_tree',
name: i18n.t('settings_data_model'),
to: `/${currentProjectKey.value}/settings/data-model`,
},
{
icon: 'people',
name: i18n.t('settings_permissions'),
to: `/${currentProjectKey.value}/settings/roles`,
},
{
icon: 'send',
name: i18n.t('settings_webhooks'),
to: `/${currentProjectKey.value}/settings/webhooks`,
},
];
return { navItems };
},
});
</script>

View File

@@ -0,0 +1,43 @@
import { defineModule } from '@/modules/define';
import SettingsGlobal from './routes/global';
import { SettingsCollections, SettingsFields } from './routes/data-model/';
import SettingsRoles from './routes/roles';
import SettingsWebhooks from './routes/webhooks';
export default defineModule(({ i18n }) => ({
id: 'settings',
name: i18n.t('settings'),
icon: 'settings',
routes: [
{
path: '/',
redirect: '/global',
},
{
name: 'settings-global',
path: '/global',
component: SettingsGlobal,
},
{
name: 'settings-collections',
path: '/data-model',
component: SettingsCollections,
},
{
name: 'settings-fields',
path: '/data-model/:collection',
component: SettingsFields,
props: true,
},
{
name: 'settings-roles',
path: '/roles',
component: SettingsRoles,
},
{
name: 'settings-webhooks',
path: '/webhooks',
component: SettingsWebhooks,
},
],
}));

View File

@@ -0,0 +1,111 @@
<template>
<private-view :title="$t('settings_data_model')">
<template #title-outer:prepend>
<v-button rounded disabled icon secondary>
<v-icon name="account_tree" />
</v-button>
</template>
<template #actions>
<v-dialog v-model="addNewActive" persistent>
<template #activator="{ on }">
<v-button rounded icon @click="on">
<v-icon name="add" />
</v-button>
</template>
<new-collection @cancel="addNewActive = false" />
</v-dialog>
</template>
<template #navigation>
<settings-navigation />
</template>
<v-table
:headers.sync="tableHeaders"
:items="items"
@click:row="openCollection"
show-resize
>
<template #item.icon="{ item }">
<v-icon class="icon" :class="{ hidden: item.hidden }" :name="item.icon" />
</template>
<template #item.collection="{ item }">
<span class="collection" :class="{ hidden: item.hidden }">
{{ item.collection }}
</span>
</template>
<template #item.note="{ item }">
<span class="note" :class="{ hidden: item.hidden }">
{{ item.note }}
</span>
</template>
</v-table>
</private-view>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import SettingsNavigation from '../../../components/navigation/';
import NewCollection from './components/new-collection/';
import { HeaderRaw } from '../../../../../components/v-table/types';
import { i18n } from '@/lang/';
import useCollectionsStore from '@/stores/collections';
import { Collection } from '@/stores/collections/types';
import useProjectsStore from '@/stores/projects';
import router from '@/router';
export default defineComponent({
components: { SettingsNavigation, NewCollection },
setup() {
const addNewActive = ref(false);
const collectionsStore = useCollectionsStore();
const tableHeaders = ref<HeaderRaw[]>([
{
text: '',
value: 'icon',
sortable: false,
},
{
text: i18n.tc('collection', 0),
value: 'collection',
},
{
text: i18n.t('note'),
value: 'note',
},
]);
const items = computed(() => {
return collectionsStore.state.collections.filter(
({ collection }) => collection.startsWith('directus_') === false
);
});
return { addNewActive, tableHeaders, items, openCollection };
function openCollection({ collection }: Collection) {
const { currentProjectKey } = useProjectsStore().state;
router.push(`/${currentProjectKey}/settings/data-model/${collection}`);
}
},
});
</script>
<style lang="scss" scoped>
.collection {
font-family: var(--family-monospace);
}
.icon ::v-deep i {
vertical-align: baseline;
}
.hidden {
color: var(--foreground-color-secondary);
}
</style>

View File

@@ -0,0 +1,4 @@
import NewCollection from './new-collection.vue';
export { NewCollection };
export default NewCollection;

View File

@@ -0,0 +1,22 @@
<template>
<v-card>
<v-card-title>Add collection modal</v-card-title>
<v-card-actions>
<v-button secondary @click="cancel">Cancel</v-button>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
setup(props, { emit }) {
return { cancel };
function cancel() {
emit('cancel');
}
},
});
</script>

View File

@@ -0,0 +1,4 @@
import SettingsCollections from './collections.vue';
export { SettingsCollections };
export default SettingsCollections;

View File

@@ -0,0 +1,149 @@
<template>
<v-menu attached :class="hidden ? 'full' : field.width" close-on-content-click>
<template #activator="{ toggle }">
<v-input
class="field"
:class="{ hidden }"
readonly
@click="toggle"
:value="field.field"
full-width
>
<template #prepend>
<v-icon class="drag-handle" name="drag_indicator" @click.stop />
</template>
<template #append>
<v-icon name="expand_more" />
</template>
</v-input>
</template>
<v-list dense>
<v-dialog v-model="editActive" persistent>
<template #activator="{ on }">
<v-list-item @click="on">
<v-list-item-icon><v-icon name="edit" /></v-list-item-icon>
<v-list-item-content>
{{ $t('edit_field') }}
</v-list-item-content>
</v-list-item>
</template>
<field-setup :field="field" />
</v-dialog>
<v-list-item>
<v-list-item-icon><v-icon name="control_point_duplicate" /></v-list-item-icon>
<v-list-item-content>{{ $t('duplicate_field') }}</v-list-item-content>
</v-list-item>
<v-divider inset />
<v-list-item @click="setWidth('half')" :disabled="hidden || field.width === 'half'">
<v-list-item-icon><v-icon name="border_vertical" /></v-list-item-icon>
<v-list-item-content>{{ $t('half_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('full')" :disabled="hidden || field.width === 'full'">
<v-list-item-icon><v-icon name="border_right" /></v-list-item-icon>
<v-list-item-content>{{ $t('full_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('fill')" :disabled="hidden || field.width === 'fill'">
<v-list-item-icon><v-icon name="aspect_ratio" /></v-list-item-icon>
<v-list-item-content>{{ $t('fill_width') }}</v-list-item-content>
</v-list-item>
<v-divider inset />
<v-list-item @click="$emit('toggle-visibility', field)">
<template v-if="field.hidden_detail === false">
<v-list-item-icon><v-icon name="visibility_off" /></v-list-item-icon>
<v-list-item-content>{{ $t('hide_field_on_detail') }}</v-list-item-content>
</template>
<template v-else>
<v-list-item-icon><v-icon name="visibility" /></v-list-item-icon>
<v-list-item-content>{{ $t('show_field_on_detail') }}</v-list-item-content>
</template>
</v-list-item>
<v-dialog v-model="deleteActive">
<template #activator="{ on }">
<v-list-item @click="on">
<v-list-item-icon><v-icon name="delete" /></v-list-item-icon>
<v-list-item-content>
{{ $t('delete_field') }}
</v-list-item-content>
</v-list-item>
</template>
<v-card>
<v-card-title>Are you sure you want to delete this field?</v-card-title>
<v-card-actions>
<v-button @click="deleteActive = false" secondary>Cancel</v-button>
<v-button :loading="deleting" @click="deleteField">Delete</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, PropType, ref } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import useFieldsStore from '@/stores/fields/';
import FieldSetup from '../field-setup/';
export default defineComponent({
components: { FieldSetup },
props: {
field: {
type: Object as PropType<Field>,
required: true,
},
hidden: {
type: Boolean,
default: false,
},
},
setup(props) {
const editActive = ref(false);
const fieldsStore = useFieldsStore();
const { deleteActive, deleting, deleteField } = useDeleteField();
return { editActive, setWidth, deleteActive, deleting, deleteField };
function setWidth(width: string) {
fieldsStore.updateField(props.field.collection, props.field.field, { width });
}
function useDeleteField() {
const deleteActive = ref(false);
const deleting = ref(false);
return {
deleteActive,
deleting,
deleteField,
};
async function deleteField() {
await fieldsStore.deleteField(props.field.collection, props.field.field);
deleting.value = false;
deleteActive.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
// The default display: contents doens't play nicely with drag and drop
.v-menu {
display: block;
}
.full,
.fill {
grid-column: 1 / span 2;
}
.v-input.hidden {
--input-background-color: var(--background-color-alt);
}
</style>

View File

@@ -0,0 +1,4 @@
import FieldSelect from './field-select.vue';
export { FieldSelect };
export default FieldSelect;

View File

@@ -0,0 +1,11 @@
<template>
<v-card>
Field Setup
</v-card>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({});
</script>

View File

@@ -0,0 +1,4 @@
import FieldSetup from './field-setup.vue';
export { FieldSetup };
export default FieldSetup;

View File

@@ -0,0 +1,205 @@
<template>
<div class="fields-management">
<draggable
class="visible"
:value="sortedVisibleFields"
handle=".drag-handle"
group="fields"
@change="($event) => handleChange($event, 'visible')"
>
<field-select
v-for="field in sortedVisibleFields"
:key="field.field"
:field="field"
@toggle-visibility="toggleVisibility($event, 'visible')"
/>
</draggable>
<v-divider>{{ $t('hidden_detail') }}</v-divider>
<draggable
class="hidden"
:value="sortedHiddenFields"
handle=".drag-handle"
group="fields"
@change="($event) => handleChange($event, 'hidden')"
>
<field-select
v-for="field in sortedHiddenFields"
:key="field.field"
:field="field"
hidden
@toggle-visibility="toggleVisibility($event, 'hidden')"
/>
</draggable>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import useCollection from '@/compositions/use-collection/';
import Draggable from 'vuedraggable';
import { Field } from '@/stores/fields/types';
import useFieldsStore from '@/stores/fields/';
import FieldSelect from '../field-select/';
type DraggableEvent = {
moved?: {
element: Field;
newIndex: number;
oldIndex: number;
};
added?: {
element: Field;
newIndex: number;
};
};
export default defineComponent({
components: { Draggable, FieldSelect },
props: {
collection: {
type: String,
required: true,
},
},
setup(props) {
const { fields } = useCollection(props.collection);
const fieldsStore = useFieldsStore();
const sortedVisibleFields = computed(() =>
[...fields.value]
.filter(({ hidden_detail }) => hidden_detail === false)
.sort((a, b) => {
return (a.sort || 0) > (b.sort || 0) ? 1 : -1;
})
);
const sortedHiddenFields = computed(() =>
[...fields.value]
.filter(({ hidden_detail }) => hidden_detail === true)
.sort((a, b) => {
return (a.sort || -1) > (b.sort || -1) ? 1 : -1;
})
);
return { sortedVisibleFields, sortedHiddenFields, handleChange, toggleVisibility };
function handleChange(event: DraggableEvent, location: 'visible' | 'hidden') {
if (event.added !== undefined) {
addToGroup(event.added, location);
}
if (event.moved !== undefined) {
sortInGroup(event.moved, location);
}
}
function toggleVisibility(field: Field, location: 'visible' | 'hidden') {
const fields =
location === 'hidden' ? sortedVisibleFields.value : sortedHiddenFields.value;
handleChange(
{ added: { element: field, newIndex: fields.length } },
location === 'hidden' ? 'visible' : 'hidden'
);
}
function addToGroup(
event: Required<DraggableEvent>['added'],
location: 'visible' | 'hidden'
) {
/** @NOTE Adding to one group also means removing from the other */
const { element, newIndex } = event;
const fieldsInGroup =
location === 'visible' ? sortedVisibleFields.value : sortedHiddenFields.value;
const updates: Partial<Field>[] = fieldsInGroup.slice(newIndex).map((field) => {
const sortValue =
field.sort ||
fieldsInGroup.findIndex((existingField) => existingField.field === field.field);
return {
field: field.field,
sort: sortValue + 1,
};
});
const addedToEnd = newIndex === fieldsInGroup.length;
let newSortValue = fieldsInGroup[newIndex]?.sort;
if (!newSortValue && addedToEnd) {
const previousItem = fieldsInGroup[newIndex - 1];
if (previousItem && previousItem.sort) newSortValue = previousItem.sort + 1;
}
if (!newSortValue) {
newSortValue = newIndex;
}
updates.push({
field: element.field,
sort: newSortValue,
hidden_detail: location === 'hidden',
});
fieldsStore.updateFields(element.collection, updates);
}
function sortInGroup(
event: Required<DraggableEvent>['moved'],
location: 'visible' | 'hidden'
) {
const { element, newIndex, oldIndex } = event;
const move = newIndex > oldIndex ? 'down' : 'up';
const selectionRange =
move === 'down' ? [oldIndex + 1, newIndex + 1] : [newIndex, oldIndex];
const fields =
location === 'visible' ? sortedVisibleFields.value : sortedHiddenFields.value;
const updates: Partial<Field>[] = fields.slice(...selectionRange).map((field) => {
// If field.sort isn't set yet, base it on the index of the array. That way, the
// new sort value will match what's visible on the screen
const sortValue =
field.sort ||
fields.findIndex((existingField) => existingField.field === field.field);
return {
field: field.field,
sort: move === 'down' ? sortValue - 1 : sortValue + 1,
};
});
const sortOfItemOnNewIndex = fields[newIndex].sort || newIndex;
updates.push({
field: element.field,
sort: sortOfItemOnNewIndex,
});
fieldsStore.updateFields(element.collection, updates);
}
},
});
</script>
<style lang="scss" scoped>
.v-divider {
margin: var(--private-view-content-padding) 0;
}
.visible,
.hidden {
display: grid;
grid-gap: 24px 36px;
grid-template-columns: 1fr 1fr;
}
.list-move {
transition: transform 0.5s;
}
</style>

View File

@@ -0,0 +1,4 @@
import FieldsManagement from './fields-management.vue';
export { FieldsManagement };
export default FieldsManagement;

View File

@@ -0,0 +1,48 @@
<template>
<private-view :title="collectionInfo.name">
<template #navigation>
<settings-navigation />
</template>
<div class="fields">
<h2 class="title">{{ $t('fields_and_layout') }}</h2>
<fields-management :collection="collection" />
</div>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../../components/navigation/';
import useCollection from '@/compositions/use-collection/';
import FieldsManagement from './components/fields-management';
export default defineComponent({
components: { SettingsNavigation, FieldsManagement },
props: {
collection: {
type: String,
required: true,
},
},
setup(props) {
const { info: collectionInfo, fields } = useCollection(props.collection);
return { collectionInfo, fields };
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/type-styles';
.title {
margin-bottom: 12px;
@include type-heading-small;
}
.fields {
max-width: 800px;
padding: var(--private-view-content-padding);
}
</style>

View File

@@ -0,0 +1,4 @@
import SettingsFields from './fields.vue';
export { SettingsFields };
export default SettingsFields;

View File

@@ -0,0 +1,4 @@
import { SettingsCollections } from './collections';
import { SettingsFields } from './fields';
export { SettingsCollections, SettingsFields };

View File

@@ -0,0 +1,16 @@
<template>
<private-view :title="$t('settings_global')">
<template #navigation>
<settings-navigation />
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation/';
export default defineComponent({
components: { SettingsNavigation },
});
</script>

View File

@@ -0,0 +1,4 @@
import SettingsGlobal from './global.vue';
export { SettingsGlobal };
export default SettingsGlobal;

View File

@@ -0,0 +1,16 @@
<template>
<private-view :title="$t('settings_global')">
<template #navigation>
<settings-navigation />
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../../components/navigation/';
export default defineComponent({
components: { SettingsNavigation },
});
</script>

View File

@@ -0,0 +1,4 @@
import SettingsGlobal from './global.vue';
export { SettingsGlobal };
export default SettingsGlobal;

View File

@@ -0,0 +1,4 @@
import SettingsRoles from './roles.vue';
export { SettingsRoles };
export default SettingsRoles;

View File

@@ -0,0 +1,16 @@
<template>
<private-view :title="$t('settings_permissions')">
<template #navigation>
<settings-navigation />
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation/';
export default defineComponent({
components: { SettingsNavigation },
});
</script>

View File

@@ -0,0 +1,4 @@
import SettingsWebhooks from './webhooks.vue';
export { SettingsWebhooks };
export default SettingsWebhooks;

View File

@@ -0,0 +1,16 @@
<template>
<private-view :title="$t('settings_webhooks')">
<template #navigation>
<settings-navigation />
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation/';
export default defineComponent({
components: { SettingsNavigation },
});
</script>

View File

@@ -16,7 +16,7 @@ describe('Routes / Logout', () => {
localVue,
});
await component.vm.$nextTick();
await (component.vm as any).$nextTick();
expect(logout).toHaveBeenCalled();
});

View File

@@ -6,6 +6,7 @@ import VueI18n from 'vue-i18n';
import { notEmpty } from '@/utils/is-empty/';
import { i18n } from '@/lang';
import formatTitle from '@directus/format-title';
import notify from '@/utils/notify';
export const useFieldsStore = createStore({
id: 'fieldsStore',
@@ -49,6 +50,143 @@ export const useFieldsStore = createStore({
async dehydrate() {
this.reset();
},
async updateField(
collectionKey: string,
fieldKey: string,
updates: Record<string, Partial<Field>>
) {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const stateClone = [...this.state.fields];
// Update locally first, so the changes are visible immediately
this.state.fields = this.state.fields.map((field) => {
if (field.collection === collectionKey && field.field === fieldKey) {
return {
...field,
...updates,
};
}
return field;
});
// Save to API, and update local state again to make sure everything is in sync with the
// API
try {
const response = await api.patch(
`/${currentProjectKey}/fields/${collectionKey}/${fieldKey}`,
updates
);
this.state.fields = this.state.fields.map((field) => {
if (field.collection === collectionKey && field.field === fieldKey) {
return response.data.data;
}
return field;
});
notify({
title: i18n.t('field_update_success', { field: fieldKey }),
type: 'success',
});
} catch (error) {
notify({
title: i18n.t('field_update_failure', { field: fieldKey }),
type: 'error',
});
// reset the changes if the api sync failed
this.state.fields = stateClone;
throw error;
}
},
async updateFields(collectionKey: string, updates: Partial<Field>[]) {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const stateClone = [...this.state.fields];
// Update locally first, so the changes are visible immediately
this.state.fields = this.state.fields.map((field) => {
if (field.collection === collectionKey) {
const updatesForThisField = updates.find(
(update) => update.field === field.field
);
if (updatesForThisField) {
return {
...field,
...updatesForThisField,
};
}
}
return field;
});
try {
// Save to API, and update local state again to make sure everything is in sync with the
// API
const response = await api.patch(
`/${currentProjectKey}/fields/${collectionKey}`,
updates
);
this.state.fields = this.state.fields.map((field) => {
if (field.collection === collectionKey) {
const newDataForField = response.data.data.find(
(update: Field) => update.field === field.field
);
if (newDataForField) return newDataForField;
}
return field;
});
notify({
title: i18n.t('fields_update_success'),
text: updates.map(({ field }) => field).join(', '),
type: 'success',
});
} catch (error) {
notify({
title: i18n.t('fields_update_failed'),
text: updates.map(({ field }) => field).join(', '),
type: 'error',
});
// reset the changes if the api sync failed
this.state.fields = stateClone;
throw error;
}
},
async deleteField(collectionKey: string, fieldKey: string) {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const stateClone = [...this.state.fields];
this.state.fields = this.state.fields.filter((field) => {
if (field.field === fieldKey && field.collection === collectionKey) return false;
return true;
});
try {
await api.delete(`/${currentProjectKey}/fields/${collectionKey}/${fieldKey}`);
notify({
title: i18n.t('field_delete_success', { field: fieldKey }),
type: 'success',
});
} catch (error) {
notify({
title: i18n.t('field_delete_failure', { field: fieldKey }),
type: 'error',
});
this.state.fields = stateClone;
throw error;
}
},
getPrimaryKeyFieldForCollection(collection: string) {
/** @NOTE it's safe to assume every collection has a primary key */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion

View File

@@ -1,8 +1,10 @@
import VueI18n from 'vue-i18n';
export interface NotificationRaw {
id?: string;
title: string;
persist?: boolean;
text?: string;
title: string | VueI18n.TranslateResult;
text?: string | VueI18n.TranslateResult;
type?: 'info' | 'success' | 'warning' | 'error';
icon?: string | null;
}

View File

@@ -0,0 +1,4 @@
import moveInArray from './move-in-array';
export { moveInArray };
export default moveInArray;

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function moveInArray(array: readonly any[], fromIndex: number, toIndex: number) {
const item = array[fromIndex];
const length = array.length;
const diff = fromIndex - toIndex;
if (diff > 0) {
// move left
return [
...array.slice(0, toIndex),
item,
...array.slice(toIndex, fromIndex),
...array.slice(fromIndex + 1, length),
];
} else if (diff < 0) {
// move right
const targetIndex = toIndex + 1;
return [
...array.slice(0, fromIndex),
...array.slice(fromIndex + 1, targetIndex),
item,
...array.slice(targetIndex, length),
];
}
return array;
}

View File

@@ -1,12 +1,21 @@
<template>
<div class="project-chooser">
<span>{{ currentProjectKey }}</span>
<select :value="currentProjectKey" @change="navigateToProject">
<option v-for="project in projects" :key="project.key" :value="project.key">
<v-menu attached>
<template #activator="{ toggle }">
<div class="project-chooser">
<span @click="toggle">{{ currentProjectKey }}</span>
</div>
</template>
<v-list>
<v-list-item
v-for="project in projects"
:key="project.key"
@click="navigateToProject(project.key)"
>
{{ (project.api && project.api.project_name) || project.key }}
</option>
</select>
</div>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
@@ -26,9 +35,9 @@ export default defineComponent({
projectsStore,
};
function navigateToProject(event: InputEvent) {
function navigateToProject(key: string) {
router
.push(`/${(event.target as HTMLSelectElement).value}/collections`)
.push(`/${key}/collections`)
/** @NOTE
* Vue Router considers a navigation change _in_ the navigation guard a rejection
* so when this push goes from /collections to /login, it will throw.