Migrate Settings and SettingsGroup

This commit is contained in:
wvffle 2022-07-04 22:02:39 +00:00 committed by Georg Krause
parent 58eec54952
commit b597dc4a71
5 changed files with 322 additions and 295 deletions

View File

@ -18,6 +18,7 @@
"postinstall": "yarn run fix-fomantic-css"
},
"dependencies": {
"@vue/runtime-core": "^3.2.37",
"@vueuse/core": "8.7.5",
"@vueuse/integrations": "8.7.5",
"axios": "0.27.2",

View File

@ -1,3 +1,99 @@
<script setup lang="ts">
import type { BackendError, SettingsGroup, SettingsDataEntry, FunctionRef, Form } from '~/types'
import axios from 'axios'
import SignupFormBuilder from '~/components/admin/SignupFormBuilder.vue'
import useFormData from '~/composables/useFormData'
import { ref, computed, reactive } from 'vue'
import { useStore } from '~/store'
import useLogger from '~/composables/useLogger'
interface Props {
group: SettingsGroup
settingsData: SettingsDataEntry[]
}
const props = defineProps<Props>()
const values = reactive({} as Record<string, unknown | Form | string>)
const result = ref<boolean | null>(null)
const errors = ref([] as string[])
const fileRefs = reactive({} as Record<string, HTMLInputElement>)
const setFileRef = (identifier: string) => (el: FunctionRef) => {
console.log(el)
fileRefs[identifier] = el as HTMLInputElement
}
const logger = useLogger()
const store = useStore()
const settings = computed(() => {
const byIdentifier = props.settingsData.reduce((acc, entry) => {
acc[entry.identifier] = entry
return acc
}, {} as Record<string, SettingsDataEntry>)
return props.group.settings.map(entry => {
return { ...byIdentifier[entry.name], fieldType: entry.fieldType, fieldParams: entry.fieldParams || {} }
})
})
const fileSettings = computed(() => settings.value.filter(setting => setting.field.widget.class === 'ImageWidget'))
for (const setting of settings.value) {
values[setting.identifier] = setting.value
}
const isLoading = ref(false)
const save = async () => {
errors.value = []
result.value = null
let postData: unknown = values
let contentType = 'application/json'
if (fileSettings.value.length > 0) {
const fileSettingsIDs = fileSettings.value.map((setting) => setting.identifier)
const data = settings.value.reduce((data, setting) => {
if (fileSettingsIDs.includes(setting.identifier)) {
const input = fileRefs[setting.identifier]
const { files } = input
logger.debug('ref', input, files)
if (files && files.length > 0) {
data[setting.identifier] = files[0]
}
} else {
data[setting.identifier] = values[setting.identifier] as string
}
return data
}, {} as Record<string, string | File>)
contentType = 'multipart/form-data'
postData = useFormData(data)
}
try {
const response = await axios.post('instance/admin/settings/bulk/', postData, {
headers: { 'Content-Type': contentType }
})
result.value = true
for (const setting of response.data) {
values[setting.identifier] = setting.value
}
store.dispatch('instance/fetchSettings')
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
isLoading.value = false
}
</script>
<template>
<form
:id="group.id"
@ -35,9 +131,6 @@
Settings updated successfully.
</translate>
</div>
<p v-if="group.help">
{{ group.help }}
</p>
<div
v-for="(setting, key) in settings"
:key="key"
@ -56,8 +149,8 @@
/>
<signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'"
v-model="values[setting.identifier]"
:signup-approval-enabled="values.moderation__signup_approval_enabled"
v-model="values[setting.identifier] as Form"
:signup-approval-enabled="!!values.moderation__signup_approval_enabled"
/>
<input
v-else-if="setting.field.widget.class === 'PasswordInput'"
@ -86,7 +179,7 @@
<textarea
v-else-if="setting.field.widget.class === 'Textarea'"
:id="setting.identifier"
v-model="values[setting.identifier]"
v-model="values[setting.identifier] as string"
:name="setting.identifier"
type="text"
class="ui input"
@ -97,7 +190,7 @@
>
<input
:id="setting.identifier"
v-model="values[setting.identifier]"
v-model="values[setting.identifier] as boolean"
:name="setting.identifier"
type="checkbox"
>
@ -114,8 +207,8 @@
class="ui search selection dropdown"
>
<option
v-for="(v, index) in setting.additional_data.choices"
:key="index"
v-for="v in setting.additional_data.choices"
:key="v[0]"
:value="v[0]"
>
{{ v[1] }}
@ -124,7 +217,7 @@
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
<input
:id="setting.identifier"
:ref="setting.identifier"
:ref="setFileRef(setting.identifier)"
type="file"
>
<div v-if="values[setting.identifier]">
@ -153,99 +246,3 @@
</button>
</form>
</template>
<script>
import axios from 'axios'
import { cloneDeep } from 'lodash-es'
import SignupFormBuilder from '~/components/admin/SignupFormBuilder.vue'
import useFormData from '~/composables/useFormData'
export default {
components: {
SignupFormBuilder
},
props: {
group: { type: Object, required: true },
settingsData: { type: Array, required: true }
},
data () {
return {
values: {},
result: null,
errors: [],
isLoading: false
}
},
computed: {
settings () {
const byIdentifier = {}
this.settingsData.forEach(e => {
byIdentifier[e.identifier] = e
})
return this.group.settings.map(e => {
return { ...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {} }
})
},
fileSettings () {
return this.settings.filter((s) => {
return s.field.widget.class === 'ImageWidget'
})
}
},
created () {
const self = this
this.settings.forEach(e => {
self.values[e.identifier] = e.value
})
},
methods: {
save () {
const self = this
this.isLoading = true
self.errors = []
self.result = null
let postData = self.values
let contentType = 'application/json'
const fileSettingsIDs = this.fileSettings.map((s) => { return s.identifier })
if (fileSettingsIDs.length > 0) {
const data = this.settings.reduce((data, setting) => {
if (fileSettingsIDs.includes(setting.identifier)) {
const [input] = this.$refs[setting.identifier]
const { files } = input
// TODO (wvffle): Move to the top of setup
const logger = useLogger()
logger.debug('ref', input, files)
if (files?.length > 0) {
data[s.identifier] = files[0]
}
} else {
postData.append(s.identifier, this.values[s.identifier])
}
return data
}, {})
contentType = 'multipart/form-data'
postData = useFormData(data)
}
axios.post('instance/admin/settings/bulk/', postData, {
headers: {
'Content-Type': contentType
}
}).then((response) => {
self.result = true
response.data.forEach((s) => {
self.values[s.identifier] = s.value
})
self.isLoading = false
self.$store.dispatch('instance/fetchSettings')
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
}
}
</script>

View File

@ -1,8 +1,11 @@
import type { App } from 'vue'
import type { Store } from 'vuex'
import { Router } from 'vue-router'
import { AxiosError } from 'axios'
import { RootState } from '~/store'
import type { Router } from 'vue-router'
import type { AxiosError } from 'axios'
import type { RootState } from '~/store'
import type { ComponentPublicInstance } from '@vue/runtime-core'
export type FunctionRef = Element | ComponentPublicInstance | null
declare global {
interface Window {
@ -259,3 +262,39 @@ export interface Actor {
is_local: boolean
domain: string
}
// Settings stuff
export type SettingsId = 'instance'
export interface SettingsGroup {
label: string
id: SettingsId
settings: SettingsField[]
}
export interface SettingsField {
name: string
fieldType?: 'markdown'
fieldParams?: {
charLimit: number | null
permissive: boolean
}
}
export interface SettingsDataEntry {
identifier: string
fieldType: string
fieldParams: object
help_text: string
verbose_name: string
value: unknown
field: {
class: string
widget: {
class: string
}
}
additional_data: {
choices: [string, string]
}
}

View File

@ -1,3 +1,163 @@
<script setup lang="ts">
import type { SettingsGroup } from '~/types'
import axios from 'axios'
import $ from 'jquery'
import { useCurrentElement } from '@vueuse/core'
import { ref, nextTick, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useGettext } from 'vue3-gettext'
const current = ref()
const settingsData = ref()
const { $pgettext } = useGettext()
const groups = computed(() => [
{
label: $pgettext('Content/Admin/Menu', 'Instance information'),
id: 'instance',
settings: [
{ name: 'instance__name' },
{ name: 'instance__short_description' },
{ name: 'instance__long_description', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__contact_email' },
{ name: 'instance__rules', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__terms', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__banner' },
{ name: 'instance__support_message', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }
]
},
{
label: $pgettext('*/*/*/Noun', 'Sign-ups'),
id: 'signup',
settings: [
{ name: 'users__registration_enabled' },
{ name: 'moderation__signup_approval_enabled' },
{ name: 'moderation__signup_form_customization', fieldType: 'formBuilder' }
]
},
{
label: $pgettext('*/*/*/Noun', 'Security'),
id: 'security',
settings: [
{ name: 'common__api_authentication_required' },
{ name: 'users__default_permissions' },
{ name: 'users__upload_quota' }
]
},
{
label: $pgettext('*/*/*/Noun', 'Music'),
id: 'music',
settings: [
{ name: 'music__transcoding_enabled' },
{ name: 'music__transcoding_cache_duration' }
]
},
{
label: $pgettext('*/*/*', 'Channels'),
id: 'channels',
settings: [
{ name: 'audio__channels_enabled' },
{ name: 'audio__max_channels' }
]
},
{
label: $pgettext('*/*/*', 'Playlists'),
id: 'playlists',
settings: [
{ name: 'playlists__max_tracks' }
]
},
{
label: $pgettext('*/Moderation/*', 'Moderation'),
id: 'moderation',
settings: [
{ name: 'moderation__allow_list_enabled' },
{ name: 'moderation__allow_list_public' },
{ name: 'moderation__unauthenticated_report_types' }
]
},
{
label: $pgettext('*/*/*', 'Federation'),
id: 'federation',
settings: [
{ name: 'federation__enabled' },
{ name: 'federation__public_index' },
{ name: 'federation__collection_page_size' },
{ name: 'federation__music_cache_duration' },
{ name: 'federation__actor_fetch_delay' }
]
},
{
label: $pgettext('Content/Admin/Menu', 'Subsonic'),
id: 'subsonic',
settings: [
{ name: 'subsonic__enabled' }
]
},
{
label: $pgettext('Content/Home/Header', 'Statistics'),
id: 'ui',
settings: [
{ name: 'ui__custom_css' },
{ name: 'instance__funkwhale_support_message_enabled' }
]
},
{
label: $pgettext('Content/Admin/Menu', 'User Interface'),
id: 'statistics',
settings: [
{ name: 'instance__nodeinfo_stats_enabled' },
{ name: 'instance__nodeinfo_private' }
]
}
] as SettingsGroup[])
const labels = computed(() => ({
settings: $pgettext('Head/Admin/Title', 'Instance settings')
}))
const scrollTo = (id: string) => {
current.value = id
document.getElementById(id)?.scrollIntoView()
}
const route = useRoute()
if (route.hash) {
scrollTo(route.hash.slice(1))
}
onMounted(() => {
// @ts-expect-error dropdown is from semantic ui
$('select.dropdown').dropdown()
})
const el = useCurrentElement()
watch(settingsData, async () => {
await nextTick()
// @ts-expect-error sticky is from semantic ui
$(el.value).find('.sticky').sticky({ context: '#settings-grid' })
})
const isLoading = ref(false)
const fetchSettings = async () => {
isLoading.value = true
try {
const response = await axios.get('instance/admin/settings/')
settingsData.value = response.data
} catch (error) {
// TODO (wvffle): Handle error
}
isLoading.value = false
}
await fetchSettings()
await nextTick()
</script>
<template>
<main
v-title="labels.settings"
@ -14,7 +174,7 @@
<div class="twelve wide stretched column">
<settings-group
v-for="group in groups"
:key="group.title"
:key="group.id"
:settings-data="settingsData"
:group="group"
/>
@ -40,181 +200,3 @@
</div>
</main>
</template>
<script>
import axios from 'axios'
import $ from 'jquery'
import SettingsGroup from '~/components/admin/SettingsGroup.vue'
import { nextTick } from 'vue'
import { useRoute } from 'vue-router'
export default {
components: {
SettingsGroup
},
async setup () {
await this.fetchSettings()
await nextTick()
const route = useRoute()
if (route.hash) {
this.scrollTo(route.hash.slice(1))
}
$('select.dropdown').dropdown()
},
data () {
return {
isLoading: false,
settingsData: null,
current: null
}
},
computed: {
labels () {
return {
settings: this.$pgettext('Head/Admin/Title', 'Instance settings')
}
},
groups () {
// somehow, extraction fails if in the return block directly
const instanceLabel = this.$pgettext('Content/Admin/Menu', 'Instance information')
const signupsLabel = this.$pgettext('*/*/*/Noun', 'Sign-ups')
const securityLabel = this.$pgettext('*/*/*/Noun', 'Security')
const musicLabel = this.$pgettext('*/*/*/Noun', 'Music')
const channelsLabel = this.$pgettext('*/*/*', 'Channels')
const playlistsLabel = this.$pgettext('*/*/*', 'Playlists')
const federationLabel = this.$pgettext('*/*/*', 'Federation')
const moderationLabel = this.$pgettext('*/Moderation/*', 'Moderation')
const subsonicLabel = this.$pgettext('Content/Admin/Menu', 'Subsonic')
const statisticsLabel = this.$pgettext('Content/Home/Header', 'Statistics')
const uiLabel = this.$pgettext('Content/Admin/Menu', 'User Interface')
return [
{
label: instanceLabel,
id: 'instance',
settings: [
{ name: 'instance__name' },
{ name: 'instance__short_description' },
{ name: 'instance__long_description', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__contact_email' },
{ name: 'instance__rules', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__terms', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } },
{ name: 'instance__banner' },
{ name: 'instance__support_message', fieldType: 'markdown', fieldParams: { charLimit: null, permissive: true } }
]
},
{
label: signupsLabel,
id: 'signup',
settings: [
{ name: 'users__registration_enabled' },
{ name: 'moderation__signup_approval_enabled' },
{ name: 'moderation__signup_form_customization', fieldType: 'formBuilder' }
]
},
{
label: securityLabel,
id: 'security',
settings: [
{ name: 'common__api_authentication_required' },
{ name: 'users__default_permissions' },
{ name: 'users__upload_quota' }
]
},
{
label: musicLabel,
id: 'music',
settings: [
{ name: 'music__transcoding_enabled' },
{ name: 'music__transcoding_cache_duration' }
]
},
{
label: channelsLabel,
id: 'channels',
settings: [
{ name: 'audio__channels_enabled' },
{ name: 'audio__max_channels' }
]
},
{
label: playlistsLabel,
id: 'playlists',
settings: [
{ name: 'playlists__max_tracks' }
]
},
{
label: moderationLabel,
id: 'moderation',
settings: [
{ name: 'moderation__allow_list_enabled' },
{ name: 'moderation__allow_list_public' },
{ name: 'moderation__unauthenticated_report_types' }
]
},
{
label: federationLabel,
id: 'federation',
settings: [
{ name: 'federation__enabled' },
{ name: 'federation__public_index' },
{ name: 'federation__collection_page_size' },
{ name: 'federation__music_cache_duration' },
{ name: 'federation__actor_fetch_delay' }
]
},
{
label: subsonicLabel,
id: 'subsonic',
settings: [
{ name: 'subsonic__enabled' }
]
},
{
label: uiLabel,
id: 'ui',
settings: [
{ name: 'ui__custom_css' },
{ name: 'instance__funkwhale_support_message_enabled' }
]
},
{
label: statisticsLabel,
id: 'statistics',
settings: [
{ name: 'instance__nodeinfo_stats_enabled' },
{ name: 'instance__nodeinfo_private' }
]
}
]
}
},
watch: {
settingsData () {
const self = this
this.$nextTick(() => {
$(self.$el)
.find('.sticky')
.sticky({ context: '#settings-grid' })
})
}
},
methods: {
scrollTo (id) {
this.current = id
document.getElementById(id).scrollIntoView()
},
async fetchSettings () {
const self = this
self.isLoading = true
return axios.get('instance/admin/settings/').then(response => {
self.settingsData = response.data
self.isLoading = false
})
}
}
}
</script>

View File

@ -1364,9 +1364,9 @@
"@babel/types" "^7.3.0"
"@types/dompurify@^2.3.3":
version "2.3.3"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.3.tgz#c24c92f698f77ed9cc9d9fa7888f90cf2bfaa23f"
integrity sha512-nnVQSgRVuZ/843oAfhA25eRSNzUFcBPk/LOiw5gm8mD9/X7CNcbRkQu/OsjCewO8+VIYfPxUnXvPEVGenw14+w==
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg==
dependencies:
"@types/trusted-types" "*"
@ -1870,7 +1870,7 @@
dependencies:
"@vue/shared" "3.2.37"
"@vue/reactivity@^3.2.37":
"@vue/reactivity@3.2.38", "@vue/reactivity@^3.2.37":
version "3.2.38"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e"
integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==
@ -1885,6 +1885,14 @@
"@vue/reactivity" "3.2.37"
"@vue/shared" "3.2.37"
"@vue/runtime-core@^3.2.37":
version "3.2.38"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.38.tgz#d19cf591c210713f80e6a94ffbfef307c27aea06"
integrity sha512-kk0qiSiXUU/IKxZw31824rxmFzrLr3TL6ZcbrxWTKivadoKupdlzbQM4SlGo4MU6Zzrqv4fzyUasTU1jDoEnzg==
dependencies:
"@vue/reactivity" "3.2.38"
"@vue/shared" "3.2.38"
"@vue/runtime-dom@3.2.37":
version "3.2.37"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz#002bdc8228fa63949317756fb1e92cdd3f9f4bbd"
@ -2928,9 +2936,9 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
domelementtype "^2.3.0"
dompurify@^2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f"
integrity sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw==
version "2.4.0"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
domutils@^2.5.2, domutils@^2.8.0:
version "2.8.0"