diff --git a/src/app.vue b/src/app.vue index c52d453d86..2e39ee2937 100644 --- a/src/app.vue +++ b/src/app.vue @@ -1,5 +1,5 @@ diff --git a/src/hydrate.ts b/src/hydrate.ts index 4adbc1cf01..15b5af7019 100644 --- a/src/hydrate.ts +++ b/src/hydrate.ts @@ -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[]; diff --git a/src/lang/en-US/index.json b/src/lang/en-US/index.json index d83dc4cba8..76bc65b684 100644 --- a/src/lang/en-US/index.json +++ b/src/lang/en-US/index.json @@ -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", diff --git a/src/modules/settings/routes/global/global.vue b/src/modules/settings/routes/global/global.vue index e3c25469e0..deca039505 100644 --- a/src/modules/settings/routes/global/global.vue +++ b/src/modules/settings/routes/global/global.vue @@ -1,16 +1,65 @@ + + diff --git a/src/stores/fields/fields.ts b/src/stores/fields/fields.ts index ce26cec5d8..12c8070819 100644 --- a/src/stores/fields/fields.ts +++ b/src/stores/fields/fields.ts @@ -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); }, diff --git a/src/stores/notifications/notifications.ts b/src/stores/notifications/notifications.ts index f4478e52ad..5944fa0e2f 100644 --- a/src/stores/notifications/notifications.ts +++ b/src/stores/notifications/notifications.ts @@ -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[], diff --git a/src/stores/projects/projects.ts b/src/stores/projects/projects.ts index fd4a86bfae..65b831c507 100644 --- a/src/stores/projects/projects.ts +++ b/src/stores/projects/projects.ts @@ -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 { - 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 { diff --git a/src/stores/projects/types.ts b/src/stores/projects/types.ts index c648e5b198..e9cc121169 100644 --- a/src/stores/projects/types.ts +++ b/src/stores/projects/types.ts @@ -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)[]; diff --git a/src/stores/settings/index.ts b/src/stores/settings/index.ts new file mode 100644 index 0000000000..67bfaa55fc --- /dev/null +++ b/src/stores/settings/index.ts @@ -0,0 +1,4 @@ +import { useSettingsStore } from './settings'; + +export { useSettingsStore }; +export default useSettingsStore; diff --git a/src/stores/settings/settings.test.ts b/src/stores/settings/settings.test.ts new file mode 100644 index 0000000000..275527bf4a --- /dev/null +++ b/src/stores/settings/settings.test.ts @@ -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(); + }); +}); diff --git a/src/stores/settings/settings.ts b/src/stores/settings/settings.ts new file mode 100644 index 0000000000..26d1b0fd07 --- /dev/null +++ b/src/stores/settings/settings.ts @@ -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'); + }, + }, +}); diff --git a/src/stores/settings/types.ts b/src/stores/settings/types.ts new file mode 100644 index 0000000000..db23d567bf --- /dev/null +++ b/src/stores/settings/types.ts @@ -0,0 +1,6 @@ +export type Setting = { + id: number; + key: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +}; diff --git a/src/styles/themes/_dark.scss b/src/styles/themes/_dark.scss index b5ec0e02ae..66a26dea9d 100644 --- a/src/styles/themes/_dark.scss +++ b/src/styles/themes/_dark.scss @@ -70,4 +70,8 @@ --danger-50: #884448; --danger-25: #573B40; --danger-10: #3A363B; + + .alt-colors { + --background-subdued: var(--background-page); + } } diff --git a/src/views/private/components/module-bar-logo/module-bar-logo.vue b/src/views/private/components/module-bar-logo/module-bar-logo.vue index 41c4e3ecb6..0b7f1ac5ea 100644 --- a/src/views/private/components/module-bar-logo/module-bar-logo.vue +++ b/src/views/private/components/module-bar-logo/module-bar-logo.vue @@ -12,7 +12,7 @@