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:
Rijk van Zanten
2020-04-03 20:29:04 -04:00
committed by GitHub
parent 3a0f7a6163
commit ba94e83d4e
13 changed files with 242 additions and 115 deletions

View File

@@ -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);

View File

@@ -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.

View File

@@ -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!'

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
import VSkeletonLoader from './v-skeleton-loader.vue';
export { VSkeletonLoader };
export default VSkeletonLoader;

View 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" />
`,
});

View 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>

View File

@@ -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;
}
}
}
},

View File

@@ -83,6 +83,10 @@ fieldset {
}
}
a {
text-decoration: none;
}
::selection {
background: #e1f0fa;
}

View File

@@ -16,5 +16,6 @@
--border-width: 2px;
--border-radius: 6px;
--input-height: 52px;
--input-height-tall: 168px;
--input-padding: 12px;
}