mirror of
https://github.com/directus/directus.git
synced 2026-01-27 18:48:29 -05:00
Add contextual save options to detail page (#309)
* Add skeleton loader, add loading to v-form, add disabled to v-menu * Make sure height matches with input * Add transition to skeleton loader * Force skeleton loader to adhere to input size on form * Sneak in this little thing unnoticed
This commit is contained in:
@@ -33,6 +33,7 @@ import VProgressCircular from './v-progress/circular/';
|
||||
import VRadio from './v-radio/';
|
||||
import VSelect from './v-select/';
|
||||
import VSheet from './v-sheet/';
|
||||
import VSkeletonLoader from './v-skeleton-loader/';
|
||||
import VSlider from './v-slider/';
|
||||
import VSwitch from './v-switch/';
|
||||
import VTable from './v-table/';
|
||||
@@ -75,6 +76,7 @@ Vue.component('v-progress-circular', VProgressCircular);
|
||||
Vue.component('v-radio', VRadio);
|
||||
Vue.component('v-select', VSelect);
|
||||
Vue.component('v-sheet', VSheet);
|
||||
Vue.component('v-skeleton-loader', VSkeletonLoader);
|
||||
Vue.component('v-slider', VSlider);
|
||||
Vue.component('v-switch', VSwitch);
|
||||
Vue.component('v-table', VTable);
|
||||
|
||||
@@ -13,12 +13,13 @@ Renders a form using interfaces based on the passed collection name.
|
||||
```
|
||||
|
||||
## Props
|
||||
| Prop | Description | Default |
|
||||
|-----------------|-------------------------------------------------------------------------------|---------|
|
||||
| `collection` | The collection of which you want to render the fields | -- |
|
||||
| `fields` | Array of fields to render. This can be used instead of the collection prop | -- |
|
||||
| `initialValues` | Object of the starting values of the fields | -- |
|
||||
| `edits` | The edits that were made after the form was rendered. Being used in `v-model` | -- |
|
||||
| Prop | Description | Default |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------------------------|---------|
|
||||
| `collection` | The collection of which you want to render the fields | -- |
|
||||
| `fields` | Array of fields to render. This can be used instead of the collection prop | -- |
|
||||
| `initialValues` | Object of the starting values of the fields | -- |
|
||||
| `edits` | The edits that were made after the form was rendered. Being used in `v-model` | -- |
|
||||
| `loading` | Display the form in a loading state. Prevents the ctx menus from being used and renders skeleton loaders for the fields | `false` |
|
||||
|
||||
**Note**: You have to pass either the collection or fields prop.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineComponent, ref } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { FormField } from './types';
|
||||
import { i18n } from '@/lang';
|
||||
import { withKnobs, boolean } from '@storybook/addon-knobs';
|
||||
|
||||
Vue.component('v-form', VForm);
|
||||
|
||||
@@ -14,7 +15,7 @@ export default {
|
||||
parameters: {
|
||||
notes: markdown,
|
||||
},
|
||||
decorators: [withPadding],
|
||||
decorators: [withPadding, withKnobs],
|
||||
};
|
||||
|
||||
export const collection = () =>
|
||||
@@ -67,6 +68,11 @@ export const collection = () =>
|
||||
export const fields = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
props: {
|
||||
loading: {
|
||||
default: boolean('Loading', false),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
@@ -105,6 +111,7 @@ export const fields = () =>
|
||||
template: `
|
||||
<v-form
|
||||
v-model="edits"
|
||||
:loading="loading"
|
||||
:fields="fields"
|
||||
:initial-values="{
|
||||
'third-field': 'Hello World!'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="v-form" ref="el" :class="gridClass">
|
||||
<div v-for="field in formFields" class="field" :key="field.field" :class="field.width">
|
||||
<v-menu placement="bottom-start" show-arrow close-on-content-click>
|
||||
<v-menu placement="bottom-start" show-arrow close-on-content-click :disabled="loading">
|
||||
<template #activator="{ toggle }">
|
||||
<label class="label type-label" @click="toggle">
|
||||
{{ field.name }}
|
||||
@@ -31,6 +31,7 @@
|
||||
<v-list-item-content>{{ $t('reset_to_default') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="initialValues"
|
||||
@click="setValue(field, initialValues[field.field])"
|
||||
:disabled="
|
||||
initialValues[field.field] === undefined ||
|
||||
@@ -45,12 +46,15 @@
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<interface-text-input
|
||||
:disabled="field.readonly"
|
||||
:value="values[field.field]"
|
||||
@input="setValue(field, $event)"
|
||||
v-bind="field.options"
|
||||
/>
|
||||
<div class="interface">
|
||||
<v-skeleton-loader v-if="loading" />
|
||||
<interface-text-input
|
||||
:disabled="field.readonly"
|
||||
:value="values[field.field] || field.default_value || null"
|
||||
@input="setValue(field, $event)"
|
||||
v-bind="field.options"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small class="note" v-if="field.note">{{ field.note }}</small>
|
||||
</div>
|
||||
@@ -92,6 +96,10 @@ export default defineComponent({
|
||||
type: Object as PropType<FieldValues>,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const el = ref<Element>(null);
|
||||
@@ -225,6 +233,18 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.interface {
|
||||
position: relative;
|
||||
|
||||
::v-deep .v-skeleton-loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
width: max-content;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -74,6 +74,7 @@ Strap in
|
||||
| `offsetY` | Positions the menu along the Y-Axis so as not to cover any of the activator | `false` |
|
||||
| `positionX` | "left" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
|
||||
| `positionY` | "top" css value of menu. Only works with `absolute` or `fixed` | `undefined` |
|
||||
| `disabled` | Prevent the menu from being opened by clicking on the activator | `false` |
|
||||
|
||||
### Behavior
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const activator = ref<HTMLElement>(null);
|
||||
@@ -128,6 +132,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled === true) return;
|
||||
isActive.value = !isActive.value;
|
||||
}
|
||||
}
|
||||
|
||||
4
src/components/v-skeleton-loader/index.ts
Normal file
4
src/components/v-skeleton-loader/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSkeletonLoader from './v-skeleton-loader.vue';
|
||||
|
||||
export { VSkeletonLoader };
|
||||
export default VSkeletonLoader;
|
||||
0
src/components/v-skeleton-loader/readme.md
Normal file
0
src/components/v-skeleton-loader/readme.md
Normal file
24
src/components/v-skeleton-loader/v-skeleton-loader.story.ts
Normal file
24
src/components/v-skeleton-loader/v-skeleton-loader.story.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import readme from './readme.md';
|
||||
import { withKnobs, select } from '@storybook/addon-knobs';
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default {
|
||||
title: 'Components / Skeleton Loader',
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
decorators: [withPadding, withKnobs],
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
default: select('Type', ['input', 'input-tall'], 'input'),
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<v-skeleton-loader :type="type" />
|
||||
`,
|
||||
});
|
||||
68
src/components/v-skeleton-loader/v-skeleton-loader.vue
Normal file
68
src/components/v-skeleton-loader/v-skeleton-loader.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template functional>
|
||||
<transition name="fade">
|
||||
<div class="v-skeleton-loader" :class="props.type" />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'input',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-skeleton-loader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--background-subdued);
|
||||
cursor: progress;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: loading 1.5s infinite;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input,
|
||||
.input-tall {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border: var(--border-width) solid var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.input-tall {
|
||||
height: var(--input-height-tall);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<private-view v-if="item" :title="$t('editing', { collection: currentCollection.name })">
|
||||
<collections-not-found v-if="error && error.code === 404" />
|
||||
<private-view v-else :title="$t('editing', { collection: collectionInfo.name })">
|
||||
<template #title-outer:prepend>
|
||||
<v-button rounded icon secondary exact :to="breadcrumb[1].to">
|
||||
<v-icon name="arrow_back" />
|
||||
@@ -49,28 +50,28 @@
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template>
|
||||
<v-form :initial-values="item" :collection="collection" v-model="edits" />
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<collections-navigation />
|
||||
</template>
|
||||
|
||||
<v-form
|
||||
:loading="loading"
|
||||
:initial-values="item"
|
||||
:collection="collection"
|
||||
v-model="edits"
|
||||
/>
|
||||
</private-view>
|
||||
<collections-not-found v-else />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, toRefs, watch } from '@vue/composition-api';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import api from '@/api';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
import useCollectionsStore from '../../../../stores/collections';
|
||||
import { i18n } from '@/lang';
|
||||
import router from '@/router';
|
||||
import CollectionsNotFound from '../not-found/';
|
||||
import useCollection from '@/compositions/use-collection';
|
||||
|
||||
type Values = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -92,64 +93,28 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const projectsStore = useProjectsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const { currentProjectKey } = toRefs(projectsStore.state);
|
||||
|
||||
const { info: collectionInfo } = useCollection(props.collection);
|
||||
|
||||
const isNew = computed<boolean>(() => props.primaryKey === '+');
|
||||
|
||||
const fieldsInCurrentCollection = computed<Field[]>(() => {
|
||||
return fieldsStore.state.fields.filter(
|
||||
(field) => field.collection === props.collection
|
||||
);
|
||||
});
|
||||
const edits = ref({});
|
||||
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
|
||||
|
||||
const visibleFields = computed<Field[]>(() => {
|
||||
return fieldsInCurrentCollection.value
|
||||
.filter((field) => field.hidden_browse === false)
|
||||
.sort((a, b) => (a.sort || Infinity) - (b.sort || Infinity));
|
||||
});
|
||||
|
||||
const item = ref<Values>(null);
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const confirmDelete = ref(false);
|
||||
|
||||
const currentCollection = collectionsStore.getCollection(props.collection);
|
||||
|
||||
if (isNew.value === true) {
|
||||
useDefaultValues();
|
||||
} else {
|
||||
fetchItem();
|
||||
}
|
||||
const { item, error, loading, saving, fetchItem, saveAndQuit } = useItem();
|
||||
const { breadcrumb } = useBreadcrumb();
|
||||
const { deleting, confirmDelete, deleteAndQuit } = useDelete();
|
||||
|
||||
watch(() => props.primaryKey, fetchItem);
|
||||
|
||||
const breadcrumb = computed(() => [
|
||||
{
|
||||
name: i18n.tc('collection', 2),
|
||||
to: `/${currentProjectKey.value}/collections/`,
|
||||
},
|
||||
{
|
||||
name: currentCollection.name,
|
||||
to: `/${currentProjectKey.value}/collections/${props.collection}/`,
|
||||
},
|
||||
]);
|
||||
|
||||
const edits = ref({});
|
||||
|
||||
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
|
||||
if (isNew.value === false) fetchItem();
|
||||
|
||||
return {
|
||||
visibleFields,
|
||||
item,
|
||||
loading,
|
||||
error,
|
||||
isNew,
|
||||
currentCollection,
|
||||
breadcrumb,
|
||||
edits,
|
||||
hasEdits,
|
||||
@@ -158,68 +123,93 @@ export default defineComponent({
|
||||
deleting,
|
||||
deleteAndQuit,
|
||||
confirmDelete,
|
||||
collectionInfo,
|
||||
};
|
||||
|
||||
async function fetchItem() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/${currentProjectKey.value}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
item.value = response.data.data;
|
||||
} catch (error) {
|
||||
error.value = error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
function useBreadcrumb() {
|
||||
const breadcrumb = computed(() => [
|
||||
{
|
||||
name: i18n.tc('collection', 2),
|
||||
to: `/${currentProjectKey.value}/collections/`,
|
||||
},
|
||||
{
|
||||
name: collectionInfo.value?.name,
|
||||
to: `/${currentProjectKey.value}/collections/${props.collection}/`,
|
||||
},
|
||||
]);
|
||||
|
||||
return { breadcrumb };
|
||||
}
|
||||
|
||||
function useDefaultValues() {
|
||||
const defaults: Values = {};
|
||||
function useItem() {
|
||||
const item = ref<Values>(null);
|
||||
const error = ref(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
visibleFields.value.forEach((field) => {
|
||||
defaults[field.field] = field.default_value;
|
||||
});
|
||||
return { item, error, loading, saving, fetchItem, saveAndQuit };
|
||||
|
||||
item.value = defaults;
|
||||
}
|
||||
|
||||
async function saveAndQuit() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
if (isNew.value === true) {
|
||||
await api.post(`/${currentProjectKey}/items/${props.collection}`, edits.value);
|
||||
} else {
|
||||
await api.patch(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`,
|
||||
edits.value
|
||||
async function fetchItem() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/${currentProjectKey.value}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
item.value = response.data.data;
|
||||
} catch (error) {
|
||||
error.value = error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
}
|
||||
|
||||
async function saveAndQuit() {
|
||||
saving.value = true;
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
|
||||
try {
|
||||
if (isNew.value === true) {
|
||||
await api.post(
|
||||
`/${currentProjectKey}/items/${props.collection}`,
|
||||
edits.value
|
||||
);
|
||||
} else {
|
||||
await api.patch(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`,
|
||||
edits.value
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
saving.value = true;
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAndQuit() {
|
||||
if (isNew.value === true) return;
|
||||
function useDelete() {
|
||||
const deleting = ref(false);
|
||||
const confirmDelete = ref(false);
|
||||
|
||||
deleting.value = true;
|
||||
return { deleting, confirmDelete, deleteAndQuit };
|
||||
|
||||
try {
|
||||
await api.delete(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
deleting.value = false;
|
||||
async function deleteAndQuit() {
|
||||
if (isNew.value === true) return;
|
||||
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -83,6 +83,10 @@ fieldset {
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #e1f0fa;
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@
|
||||
--border-width: 2px;
|
||||
--border-radius: 6px;
|
||||
--input-height: 52px;
|
||||
--input-height-tall: 168px;
|
||||
--input-padding: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user