Add global settings form (#303)

* Add settings store

* Use brand variable in app.vue

* wip

* Add global settings form, refactor project error type

* Fix codesmell
This commit is contained in:
Rijk van Zanten
2020-04-03 16:13:40 -04:00
committed by GitHub
parent 11f9a7f89c
commit 670103a523
16 changed files with 303 additions and 36 deletions

View File

@@ -1,5 +1,5 @@
<template>
<div id="app">
<div id="app" :style="brandStyle">
<transition name="fade">
<div class="hydrating" v-if="hydrating">
<v-progress-circular indeterminate />
@@ -11,16 +11,34 @@
</template>
<script lang="ts">
import { defineComponent, toRefs, watch } from '@vue/composition-api';
import { defineComponent, toRefs, watch, computed } from '@vue/composition-api';
import { useAppStore } from '@/stores/app';
import { useUserStore } from '@/stores/user';
import { useProjectsStore } from '@/stores/projects';
import { ProjectWithKey } from './stores/projects/types';
export default defineComponent({
setup() {
const appStore = useAppStore();
const userStore = useUserStore();
const projectsStore = useProjectsStore();
const { hydrating } = toRefs(appStore.state);
const userStore = useUserStore();
const brandStyle = computed(() => {
if (
projectsStore.currentProject.value &&
projectsStore.currentProject.value.hasOwnProperty('api')
) {
const project = projectsStore.currentProject.value as ProjectWithKey;
return {
'--brand': project?.api?.project_color || 'var(--primary)',
};
}
return null;
});
watch(
() => userStore.state.currentUser,
@@ -38,7 +56,7 @@ export default defineComponent({
}
);
return { hydrating };
return { hydrating, brandStyle };
},
});
</script>

View File

@@ -4,6 +4,7 @@ import { useFieldsStore } from '@/stores/fields/';
import { useUserStore } from '@/stores/user/';
import { useRequestsStore } from '@/stores/requests/';
import { useCollectionPresetsStore } from '@/stores/collection-presets/';
import { useSettingsStore } from '@/stores/settings/';
type GenericStore = {
id: string;
@@ -20,6 +21,7 @@ export function useStores(
useUserStore,
useRequestsStore,
useCollectionPresetsStore,
useSettingsStore,
]
) {
return stores.map((useStore) => useStore()) as GenericStore[];

View File

@@ -67,6 +67,11 @@
"page_not_found": "Page Not Found",
"page_not_found_body": "The page you are looking for doesn't seem to exist.",
"setting_update_success": "Setting {setting} updated",
"setting_update_failed": "Updating setting {setting} failed",
"settings_update_success": "Settings updated",
"settings_update_failed": "Updating settings failed",
"about_directus": "About Directus",
"activity": "Activity",
"activity_log": "Activity Log",

View File

@@ -1,16 +1,65 @@
<template>
<private-view :title="$t('settings_global')">
<template #title-outer:prepend>
<v-button rounded disabled icon secondary>
<v-icon name="public" />
</v-button>
</template>
<template #actions>
<v-button icon rounded :disabled="noEdits" :loading="saving" @click="save">
<v-icon name="check" />
</v-button>
</template>
<template #navigation>
<settings-navigation />
</template>
<div class="settings">
<v-form :initial-values="initialValues" v-model="edits" :fields="fields" />
</div>
</private-view>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, ref, computed } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation/';
import useCollection from '@/compositions/use-collection';
import useSettingsStore from '@/stores/settings';
export default defineComponent({
components: { SettingsNavigation },
setup() {
const settingsStore = useSettingsStore();
const { fields } = useCollection('directus_settings');
const initialValues = settingsStore.formatted;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const edits = ref<{ [key: string]: any }>(null);
const noEdits = computed<boolean>(
() => edits.value === null || Object.keys(edits.value).length === 0
);
const saving = ref(false);
return { fields, initialValues, edits, noEdits, saving, save };
async function save() {
if (edits.value === null) return;
saving.value = true;
await settingsStore.updateSettings(edits.value);
edits.value = null;
saving.value = false;
}
},
});
</script>
<style lang="scss" scoped>
.settings {
padding: var(--content-padding);
}
</style>

View File

@@ -18,9 +18,23 @@ export const useFieldsStore = createStore({
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const response = await api.get(`/${currentProjectKey}/fields`);
const fieldsResponse = await api.get(`/${currentProjectKey}/fields`);
const fields: FieldRaw[] = response.data.data;
const fields: FieldRaw[] = fieldsResponse.data.data.filter(
({ collection }: FieldRaw) => collection !== 'directus_settings'
);
/**
* @NOTE
*
* directus_settings is a bit of a special case. It's actual fields (key / value) are not
* what we're looking for here. Instead, we want all the "fake" fields that make up the
* form. This extra bit of logic is needed to make sure the app doesn't differentiate
* between settings and regular collections.
*/
const settingsResponse = await api.get(`/${currentProjectKey}/settings/fields`);
fields.push(...settingsResponse.data.data);
this.state.fields = fields.map(this.addTranslationsForField);
},

View File

@@ -4,7 +4,7 @@ import { nanoid } from 'nanoid';
import { reverse, sortBy } from 'lodash';
export const useNotificationsStore = createStore({
id: 'useNotifications',
id: 'notificationsStore',
state: () => ({
queue: [] as Notification[],
previous: [] as Notification[],

View File

@@ -1,5 +1,5 @@
import { createStore } from 'pinia';
import { Projects, ProjectWithKey, ProjectError } from './types';
import { ProjectWithKey } from './types';
import api from '@/api';
type LoadingError = null | {
@@ -12,11 +12,11 @@ export const useProjectsStore = createStore({
state: () => ({
needsInstall: false,
error: null as LoadingError,
projects: null as Projects | null,
projects: null as ProjectWithKey[] | null,
currentProjectKey: null as string | null,
}),
getters: {
currentProject: (state): ProjectWithKey | ProjectError | null => {
currentProject: (state): ProjectWithKey | null => {
return state.projects?.find(({ key }) => key === state.currentProjectKey) || null;
},
},
@@ -29,7 +29,7 @@ export const useProjectsStore = createStore({
* Returns a boolean if the operation succeeded or not.
*/
async setCurrentProject(key: string): Promise<boolean> {
const projects = this.state.projects || ([] as Projects);
const projects = this.state.projects || ([] as ProjectWithKey[]);
const projectKeys = projects.map((project) => project.key);
if (projectKeys.includes(key) === false) {
@@ -53,7 +53,7 @@ export const useProjectsStore = createStore({
try {
const projectsResponse = await api.get('/server/projects');
const projectKeys: string[] = projectsResponse.data.data;
const projects: Projects = [];
const projects: ProjectWithKey[] = [];
for (let index = 0; index < projectKeys.length; index++) {
try {

View File

@@ -1,5 +1,12 @@
/**
* @NOTE
*
* api, server don't exist when the project errored out.
* status, error don't exist for working projects.
*/
export interface Project {
api: {
api?: {
version?: string;
database?: string;
requires2FA: boolean;
@@ -28,19 +35,13 @@ export interface Project {
php_api: string;
};
};
status?: number;
error?: {
code: number;
message: string;
};
}
export interface ProjectWithKey extends Project {
key: string;
}
export interface ProjectError {
key: string;
status: number;
error: {
code: number;
message: string;
} | null;
}
export type Projects = (ProjectWithKey | ProjectError)[];

View File

@@ -0,0 +1,4 @@
import { useSettingsStore } from './settings';
export { useSettingsStore };
export default useSettingsStore;

View File

@@ -0,0 +1,42 @@
import useProjectsStore from '@/stores/projects';
import { useSettingsStore } from './settings';
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import api from '@/api';
jest.mock('@/api');
describe('Stores / Settings', () => {
let req = {};
beforeAll(() => {
Vue.use(VueCompositionAPI);
});
beforeEach(() => {
req = {};
});
it('Fetches the settings on hydrate', async () => {
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const settingsStore = useSettingsStore(req);
(api.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { data: [] } }));
await settingsStore.hydrate();
expect(api.get).toHaveBeenCalledWith('/my-project/settings', { params: { limit: -1 } });
});
it('Calls reset on dehydrate', async () => {
const settingsStore = useSettingsStore(req);
jest.spyOn(settingsStore, 'reset');
await settingsStore.dehydrate();
expect(settingsStore.reset).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,123 @@
import { createStore } from 'pinia';
import { Setting } from './types';
import { keyBy, mapValues } from 'lodash';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import notify from '@/utils/notify';
import { i18n } from '@/lang';
/**
* @NOTE
*
* The settings store also updates the current project in the projects store
* this allows settings like project color and name to be reflected in the app
* immediately.
*/
export const useSettingsStore = createStore({
id: 'settings',
state: () => ({
settings: [] as Setting[],
}),
actions: {
async hydrate() {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const response = await api.get(`/${currentProjectKey}/settings`, {
params: {
limit: -1,
},
});
this.state.settings = response.data.data;
},
async dehydrate() {
this.reset();
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async updateSettings(updates: { [key: string]: any }) {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const settingsCopy = [...this.state.settings];
const settingsToBeSaved = Object.keys(updates).map((key) => {
const existing = this.state.settings.find((setting) => setting.key === key);
if (existing === undefined) {
throw new Error(`Setting with key '${key}' doesn't exist.`);
}
const { id } = existing;
return {
id: id,
value: updates[key],
};
});
this.state.settings = this.state.settings.map((existingSetting) => {
const updated = settingsToBeSaved.find(
(update) => update.id === existingSetting.id
);
if (updated !== undefined) {
return {
...existingSetting,
value: updated.value,
};
}
return existingSetting;
});
try {
const response = await api.patch(
`/${currentProjectKey}/settings`,
settingsToBeSaved
);
this.state.settings = this.state.settings.map((setting) => {
const updated = response.data.data.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(update: any) => update.id === setting.id
);
if (updated !== undefined) {
return {
...setting,
value: updated.value,
};
}
return setting;
});
notify({
title: i18n.t('settings_update_success'),
text: Object.keys(updates).join(', '),
type: 'success',
});
this.updateProjectsStore();
} catch (error) {
this.state.settings = settingsCopy;
notify({
title: i18n.t('settings_update_failed'),
text: Object.keys(updates).join(', '),
type: 'error',
});
}
},
async updateProjectsStore() {
const projectsStore = useProjectsStore();
await projectsStore.getProjects();
},
},
getters: {
formatted(state) {
return mapValues(keyBy(state.settings, 'key'), 'value');
},
},
});

View File

@@ -0,0 +1,6 @@
export type Setting = {
id: number;
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
};

View File

@@ -70,4 +70,8 @@
--danger-50: #884448;
--danger-25: #573B40;
--danger-10: #3A363B;
.alt-colors {
--background-subdued: var(--background-page);
}
}

View File

@@ -12,7 +12,7 @@
<script lang="ts">
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import { ProjectWithKey, ProjectError } from '@/stores/projects/types';
import { ProjectWithKey } from '@/stores/projects/types';
import { useProjectsStore } from '@/stores/projects/';
import { useRequestsStore } from '@/stores/requests/';
@@ -23,11 +23,11 @@ export default defineComponent({
const customLogoPath = computed<string | null>(() => {
if (projectsStore.currentProject.value === null) return null;
if ((projectsStore.currentProject.value as ProjectError).error !== undefined) {
if (projectsStore.currentProject.value.error !== undefined) {
return null;
}
const currentProject = projectsStore.currentProject.value as ProjectWithKey;
return currentProject.api.project_logo?.full_url || null;
return currentProject.api?.project_logo?.full_url || null;
});
const isRunning = ref(false);

View File

@@ -77,7 +77,7 @@ describe('Views / Public', () => {
project_background: null,
},
},
];
] as any;
store.state.currentProjectKey = 'my-project';
await component.vm.$nextTick();
@@ -113,7 +113,7 @@ describe('Views / Public', () => {
store.state.projects = [mockProject];
store.state.currentProjectKey = 'my-project';
expect((component.vm as any).artStyles).toEqual({
background: `url(${mockProject.api.project_background?.full_url})`,
background: `url(${mockProject.api?.project_background?.full_url})`,
backgroundPosition: 'center center',
backgroundSize: 'cover',
});

View File

@@ -18,7 +18,6 @@ import { version } from '../../../package.json';
import { defineComponent, computed } from '@vue/composition-api';
import PublicViewLogo from './components/logo/';
import { useProjectsStore } from '@/stores/projects/';
import { ProjectWithKey, ProjectError } from '@/stores/projects/types';
export default defineComponent({
components: {
@@ -42,17 +41,17 @@ export default defineComponent({
return defaultColor;
}
if ((currentProject as ProjectError).error !== undefined) {
if (currentProject.error !== undefined) {
return defaultColor;
}
currentProject = currentProject as ProjectWithKey;
currentProject = currentProject;
if (currentProject.api.project_background?.full_url) {
return `url(${currentProject.api.project_background.full_url})`;
if (currentProject.api?.project_background?.full_url) {
return `url(${currentProject.api?.project_background.full_url})`;
}
return currentProject.api.project_color;
return currentProject.api?.project_color || defaultColor;
});
const artStyles = computed(() => ({