mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</transition>
|
||||
<router-view v-if="!hydrating" />
|
||||
<portal-target name="dialog-outlet" />
|
||||
<portal-target name="dialog-outlet" multiple />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
4
src/compositions/use-collection/index.ts
Normal file
4
src/compositions/use-collection/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useCollection } from './use-collection';
|
||||
|
||||
export { useCollection };
|
||||
export default useCollection;
|
||||
79
src/compositions/use-collection/use-collection.test.ts
Normal file
79
src/compositions/use-collection/use-collection.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
24
src/compositions/use-collection/use-collection.ts
Normal file
24
src/compositions/use-collection/use-collection.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CollectionsModule from './collections/';
|
||||
export const modules = [CollectionsModule];
|
||||
import SettingsModule from './settings/';
|
||||
export const modules = [CollectionsModule, SettingsModule];
|
||||
export default modules;
|
||||
|
||||
4
src/modules/settings/components/navigation/index.ts
Normal file
4
src/modules/settings/components/navigation/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsNavigation from './navigation.vue';
|
||||
|
||||
export { SettingsNavigation };
|
||||
export default SettingsNavigation;
|
||||
48
src/modules/settings/components/navigation/navigation.vue
Normal file
48
src/modules/settings/components/navigation/navigation.vue
Normal 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>
|
||||
43
src/modules/settings/index.ts
Normal file
43
src/modules/settings/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}));
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import NewCollection from './new-collection.vue';
|
||||
|
||||
export { NewCollection };
|
||||
export default NewCollection;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import SettingsCollections from './collections.vue';
|
||||
|
||||
export { SettingsCollections };
|
||||
export default SettingsCollections;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import FieldSelect from './field-select.vue';
|
||||
|
||||
export { FieldSelect };
|
||||
export default FieldSelect;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import FieldSetup from './field-setup.vue';
|
||||
|
||||
export { FieldSetup };
|
||||
export default FieldSetup;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import FieldsManagement from './fields-management.vue';
|
||||
|
||||
export { FieldsManagement };
|
||||
export default FieldsManagement;
|
||||
48
src/modules/settings/routes/data-model/fields/fields.vue
Normal file
48
src/modules/settings/routes/data-model/fields/fields.vue
Normal 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>
|
||||
4
src/modules/settings/routes/data-model/fields/index.ts
Normal file
4
src/modules/settings/routes/data-model/fields/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsFields from './fields.vue';
|
||||
|
||||
export { SettingsFields };
|
||||
export default SettingsFields;
|
||||
4
src/modules/settings/routes/data-model/index.ts
Normal file
4
src/modules/settings/routes/data-model/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SettingsCollections } from './collections';
|
||||
import { SettingsFields } from './fields';
|
||||
|
||||
export { SettingsCollections, SettingsFields };
|
||||
16
src/modules/settings/routes/global/global.vue
Normal file
16
src/modules/settings/routes/global/global.vue
Normal 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>
|
||||
4
src/modules/settings/routes/global/index.ts
Normal file
4
src/modules/settings/routes/global/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsGlobal from './global.vue';
|
||||
|
||||
export { SettingsGlobal };
|
||||
export default SettingsGlobal;
|
||||
16
src/modules/settings/routes/roles/global/global.vue
Normal file
16
src/modules/settings/routes/roles/global/global.vue
Normal 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>
|
||||
4
src/modules/settings/routes/roles/global/index.ts
Normal file
4
src/modules/settings/routes/roles/global/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsGlobal from './global.vue';
|
||||
|
||||
export { SettingsGlobal };
|
||||
export default SettingsGlobal;
|
||||
4
src/modules/settings/routes/roles/index.ts
Normal file
4
src/modules/settings/routes/roles/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsRoles from './roles.vue';
|
||||
|
||||
export { SettingsRoles };
|
||||
export default SettingsRoles;
|
||||
16
src/modules/settings/routes/roles/roles.vue
Normal file
16
src/modules/settings/routes/roles/roles.vue
Normal 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>
|
||||
4
src/modules/settings/routes/webhooks/index.ts
Normal file
4
src/modules/settings/routes/webhooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SettingsWebhooks from './webhooks.vue';
|
||||
|
||||
export { SettingsWebhooks };
|
||||
export default SettingsWebhooks;
|
||||
16
src/modules/settings/routes/webhooks/webhooks.vue
Normal file
16
src/modules/settings/routes/webhooks/webhooks.vue
Normal 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>
|
||||
@@ -16,7 +16,7 @@ describe('Routes / Logout', () => {
|
||||
localVue,
|
||||
});
|
||||
|
||||
await component.vm.$nextTick();
|
||||
await (component.vm as any).$nextTick();
|
||||
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
4
src/utils/move-in-array/index.ts
Normal file
4
src/utils/move-in-array/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import moveInArray from './move-in-array';
|
||||
|
||||
export { moveInArray };
|
||||
export default moveInArray;
|
||||
26
src/utils/move-in-array/move-in-array.ts
Normal file
26
src/utils/move-in-array/move-in-array.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user