mirror of
https://github.com/directus/directus.git
synced 2026-01-27 17:38:00 -05:00
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:
26
src/app.vue
26
src/app.vue
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)[];
|
||||
|
||||
4
src/stores/settings/index.ts
Normal file
4
src/stores/settings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useSettingsStore } from './settings';
|
||||
|
||||
export { useSettingsStore };
|
||||
export default useSettingsStore;
|
||||
42
src/stores/settings/settings.test.ts
Normal file
42
src/stores/settings/settings.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
123
src/stores/settings/settings.ts
Normal file
123
src/stores/settings/settings.ts
Normal 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');
|
||||
},
|
||||
},
|
||||
});
|
||||
6
src/stores/settings/types.ts
Normal file
6
src/stores/settings/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Setting = {
|
||||
id: number;
|
||||
key: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
};
|
||||
@@ -70,4 +70,8 @@
|
||||
--danger-50: #884448;
|
||||
--danger-25: #573B40;
|
||||
--danger-10: #3A363B;
|
||||
|
||||
.alt-colors {
|
||||
--background-subdued: var(--background-page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user