create, edit, delete project branding

This commit is contained in:
einhornimmond 2025-02-15 12:43:14 +01:00
parent b3290a8191
commit c160c39378
14 changed files with 540 additions and 10 deletions

View File

@ -55,7 +55,8 @@
"vue-router": "4.4.0",
"vue3-datepicker": "^0.4.0",
"vuex": "4.1.0",
"vuex-persistedstate": "4.1.0"
"vuex-persistedstate": "4.1.0",
"yup": "^1.6.1"
},
"devDependencies": {
"@apollo/client": "^3.10.8",

View File

@ -56,7 +56,7 @@ export default {
? formatDistanceToNow(new Date(dateString), {
includeSecond: true,
addSuffix: true,
locale: useDateLocale,
locale: useDateLocale(),
})
: ''
},

View File

@ -32,6 +32,9 @@
<BNavItem to="/federation" :active="isActive('federation')">
{{ $t('navbar.instances') }}
</BNavItem>
<BNavItem to="/projectBranding" :active="isActive('projectBranding')" :title="$t('navbar.projectBrandingTooltip')">
{{ $t('navbar.projectBranding') }}
</BNavItem>
<BNavItem to="/statistic" :active="isActive('statistic')">
{{ $t('navbar.statistic') }}
</BNavItem>

View File

@ -0,0 +1,129 @@
<template>
<div class="project-branding-form">
<BForm @submit.prevent="submit">
<ValidatedInput
:model-value="name"
name="name"
:label="$t('name')"
:rules="validationSchema.fields.name"
class="mb-3"
@update:model-value="updateField"
/>
<ValidatedInput
:model-value="alias"
name="alias"
:label="$t('alias')"
:rules="validationSchema.fields.alias"
class="mb-3"
@update:model-value="updateField"
/>
<ValidatedInput
:model-value="description"
name="description"
:label="$t('description')"
:rules="validationSchema.fields.description"
textarea="true"
class="mb-3"
@update:model-value="updateField"
/>
<BFormGroup
:label="$t('projectBranding.newUserToSpace')"
label-for="newUserToSpace-input-field"
class="mb-3"
>
<BFormCheckbox
id="newUserToSpace-input-field"
:model-value="newUserToSpace"
name="newUserToSpace"
value="true"
unchecked-value="false"
@update:model-value="(value) => updateField(value, 'newUserToSpace')"
>
{{ $t('projectBranding.newUserToSpaceTooltip') }}
</BFormCheckbox>
</BFormGroup>
<ValidatedInput
:model-value="logoUrl"
name="logoUrl"
:label="$t('logo')"
:rules="validationSchema.fields.logoUrl"
class="mb-3"
@update:model-value="updateField"
/>
<BFormInvalidFeedback v-if="errorMessage" class="d-block mb-3">
{{ errorMessage }}
</BFormInvalidFeedback>
<div class="d-flex gap-2">
<BButton type="submit" variant="primary">{{ $t('save') }}</BButton>
<BButton type="reset" variant="secondary" @click="resetForm">{{ $t('reset') }}</BButton>
</div>
</BForm>
</div>
</template>
<script setup>
import ValidatedInput from '@/components/input/ValidatedInput'
import { reactive, computed, watch, ref } from 'vue'
import { object, string, boolean } from 'yup'
const props = defineProps({
modelValue: { type: Object, required: true },
})
const form = reactive({ ...props.modelValue })
const errorMessage = ref('')
watch(
() => props.modelValue,
(newValue) => Object.assign(form, newValue),
)
const name = computed(() => form.name)
const alias = computed(() => form.alias)
const description = computed(() => form.description)
const newUserToSpace = computed(() => form.newUserToSpace)
const logoUrl = computed(() => form.logoUrl)
const validationSchema = object({
name: string().min(3).max(255).required(),
alias: string()
.matches(/^[a-z0-9-_]+$/, {
message: 'Alias can only contain lowercase letters, numbers, hyphens, and underscores.',
})
.min(3)
.max(32)
.required(),
description: string().nullable().optional(),
newUserToSpace: boolean().optional(),
logoUrl: string().url('Logo URL must be a valid URL.').nullable().optional(),
})
function updateField(value, name) {
form[name] = value
}
const emit = defineEmits(['update:modelValue'])
function submit() {
validationSchema
.validate(form, { stripUnknown: true })
.then((cleanedForm) => {
emit('update:modelValue', { ...cleanedForm, id: props.modelValue.id })
})
.catch((err) => {
errorMessage.value = err.message
})
}
function resetForm() {
if (props.modelValue.id === undefined) {
Object.assign(form, {
name: '',
alias: '',
description: undefined,
newUserToSpace: false,
logoUrl: undefined,
})
return
} else {
Object.assign(form, props.modelValue)
}
errorMessage.value = ''
}
</script>

View File

@ -0,0 +1,69 @@
<template>
<div class="project-branding-item">
<BRow :title="item.description" @click="details = !details">
<BCol cols="3">{{ item.name }}</BCol>
<BCol cols="2">{{ item.alias }}</BCol>
<BCol cols="2">{{ item.newUserToSpace }}</BCol>
<BCol cols="3"><img :src="item.logoUrl" :alt="item.logoUrl" /></BCol>
<BCol cols="1">
<BButton v-b-tooltip.hover variant="danger" :title="$t('delete')" @click.stop="deleteItem">
<i class="fas fa-trash-alt"></i>
</BButton>
</BCol>
</BRow>
<BRow v-if="details || item.id === undefined" class="details">
<BCol colspan="5">
<BCard>
<ProjectBrandingForm :model-value="item" @update:model-value="update" />
</BCard>
</BCol>
</BRow>
</div>
</template>
<script setup>
import { ref, toRefs } from 'vue'
import ProjectBrandingForm from './ProjectBrandingForm.vue'
import { useMutation } from '@vue/apollo-composable'
import gql from 'graphql-tag'
const props = defineProps({
item: { type: Object, required: true },
})
const { item } = toRefs(props)
const details = ref(false)
const emit = defineEmits(['update:item', 'deleted:item'])
function update(form) {
const { mutate } = useMutation(gql`
mutation upsertProjectBranding($input: ProjectBrandingInput!) {
upsertProjectBranding(input: $input) {
id
name
alias
newUserToSpace
logoUrl
}
}
`)
mutate({
input: { ...form },
}).then(({ data }) => {
emit('update:item', data.upsertProjectBranding)
})
}
function deleteItem() {
const { mutate } = useMutation(gql`
mutation deleteProjectBranding($id: ID!) {
deleteProjectBranding(id: $id)
}
`)
mutate({
id: item.value.id,
}).then(() => {
emit('deleted:item', item.value.id)
})
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<div :class="wrapperClassName">
<BFormGroup :label="label" :label-for="labelFor">
<BFormTextarea
v-if="textarea"
v-bind="{ ...$attrs, id: labelFor, name }"
v-model="model"
trim
:rows="4"
:max-rows="4"
no-resize
/>
<BFormInput v-else v-bind="{ ...$attrs, id: labelFor, name }" v-model="model" />
<slot></slot>
</BFormGroup>
</div>
</template>
<script setup>
import { computed, defineOptions, defineModel } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps({
label: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
textarea: {
type: Boolean,
required: false,
default: false,
},
})
const model = defineModel()
const wrapperClassName = computed(() => (props.name ? `input-${props.name}` : 'input'))
const labelFor = computed(() => `${props.name}-input-field`)
</script>

View File

@ -0,0 +1,90 @@
<template>
<LabeledInput
v-bind="$attrs"
:min="minValue"
:max="maxValue"
:model-value="model"
:reset-value="resetValue"
:locale="$i18n.locale"
:required="!isOptional"
:label="label"
:name="name"
:state="valid"
@update:model-value="updateValue"
>
<BFormInvalidFeedback v-if="errorMessage">
{{ errorMessage }}
</BFormInvalidFeedback>
</LabeledInput>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import LabeledInput from './LabeledInput'
import { translateYupErrorString } from '@/validationSchemas'
const props = defineProps({
label: {
type: String,
required: true,
},
modelValue: [String, Number, Date],
name: {
type: String,
required: true,
},
rules: {
type: Object,
required: true,
},
})
const { t } = useI18n()
const model = ref(props.modelValue)
const valid = computed(() => {
if (
(props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) &&
isOptional.value
) {
return null
}
return props.rules.isValidSync(props.modelValue)
})
const errorMessage = computed(() => {
if (props.modelValue === undefined || props.modelValue === '' || props.modelValue === null) {
return undefined
}
try {
props.rules.validateSync(props.modelValue)
return undefined
} catch (e) {
return translateYupErrorString(e.message, t)
}
})
const emit = defineEmits(['update:modelValue'])
const updateValue = (newValue) => {
emit('update:modelValue', newValue, props.name, valid.value)
}
// update model and if value changed and model isn't null, check validation,
// for loading Input with existing value and show correct validation state
watch(
() => props.modelValue,
() => {
model.value = props.modelValue
},
)
// extract additional parameter like min and max from schema
const schemaDescription = computed(() => props.rules.describe())
const getTestParameter = (name) =>
schemaDescription.value?.tests?.find((t) => t.name === name)?.params[name]
const minValue = computed(() => getTestParameter('min'))
const maxValue = computed(() => getTestParameter('max'))
const resetValue = computed(() => schemaDescription.value.default)
const isOptional = computed(() => schemaDescription.value.optional)
</script>

View File

@ -1,5 +1,7 @@
{
"GDD": "GDD",
"actions": "Aktionen",
"alias": "Alias",
"all_emails": "Alle Nutzer",
"back": "zurück",
"change_user_role": "Nutzerrolle ändern",
@ -52,7 +54,6 @@
"enter_text": "Text eintragen",
"form": "Schöpfungsformular",
"min_characters": "Mindestens 10 Zeichen eingeben",
"reset": "Zurücksetzen",
"select_month": "Monat auswählen",
"select_value": "Betrag auswählen",
"submit_creation": "Schöpfung einreichen",
@ -68,6 +69,7 @@
"deleted": "gelöscht",
"deleted_user": "Alle gelöschten Nutzer",
"deny": "Ablehnen",
"description": "Beschreibung",
"e_mail": "E-Mail",
"edit": "bearbeiten",
"enabled": "aktiviert",
@ -127,6 +129,7 @@
"lastname": "Nachname",
"latitude": "Breitengrad:",
"latitude-longitude-smart": "Breitengrad, Längengrad",
"logo": "Logo",
"longitude": "Längengrad:",
"math": {
"equals": "=",
@ -155,6 +158,8 @@
"instances": "Instanzen",
"logout": "Abmelden",
"my-account": "Mein Konto",
"projectBranding": "Projekt Branding",
"projectBrandingTooltip": "Nutze ein eigenes Logo im Gradido Login und füge neue Benutzer einem Humhub-Space hinzu",
"statistic": "Statistik",
"user_search": "Nutzersuche"
},
@ -199,8 +204,15 @@
"yes": "Ja, Nutzer wiederherstellen"
}
},
"projectBranding": {
"addTooltip": "Neuen Projekt Branding Eintrag hinzufügen",
"title": "Projekt Brandings",
"newUserToSpace": "Benutzer hinzufügen?",
"newUserToSpaceTooltip": "Neue Benutzer automatisch zum Space hinzufügen, falls Space vorhanden"
},
"redeemed": "eingelöst",
"removeNotSelf": "Als Admin/Moderator kannst du dich nicht selber löschen.",
"reset": "Zurücksetzen",
"save": "Speichern",
"statistic": {
"activeUsers": "Aktive Mitglieder",

View File

@ -1,5 +1,7 @@
{
"GDD": "GDD",
"actions": "Actions",
"alias": "Alias",
"all_emails": "All users",
"back": "back",
"change_user_role": "Change user role",
@ -68,6 +70,7 @@
"deleted": "deleted",
"deleted_user": "All deleted user",
"deny": "Reject",
"description": "Description",
"e_mail": "E-mail",
"edit": "edit",
"enabled": "enabled",
@ -127,6 +130,7 @@
"lastname": "Lastname",
"latitude": "Latitude:",
"latitude-longitude-smart": "Latitude, Longitude",
"logo": "Logo",
"longitude": "Longitude:",
"math": {
"equals": "=",
@ -155,6 +159,8 @@
"instances": "Instances",
"logout": "Logout",
"my-account": "My Account",
"projectBranding": "Project Branding",
"projectBrandingTooltip": "Use your own logo in the Gradido login and add new users to a Humhub space",
"statistic": "Statistic",
"user_search": "User search"
},
@ -199,9 +205,16 @@
"yes": "Yes,undelete user"
}
},
"projectBranding": {
"addTooltip": "Add new project branding entry",
"title": "Project Branding",
"newUserToSpace": "Add user?",
"newUserToSpaceTooltip": "The hours should contain a maximum of two decimal places"
},
"redeemed": "redeemed",
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
"save": "Speichern",
"reset": "Reset",
"save": "Save",
"statistic": {
"activeUsers": "Active members",
"count": "Count",

View File

@ -0,0 +1,119 @@
<template>
<div class="project-branding">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="h2">{{ $t('projectBranding.title') }}</span>
<div>
<BButton
variant="primary"
data-test="project-branding-add-btn"
font-scale="2"
class="me-3"
:title="$t('projectBranding.addTooltip')"
:disabled="isAddButtonDisabled"
@click="createEntry"
>
<IBiPlus />
</BButton>
<BButton
:animation="animation"
data-test="project-branding-refresh-btn"
font-scale="2"
@click="refetch"
>
<IBiArrowClockwise />
</BButton>
</div>
</div>
<BListGroup>
<BRow>
<BCol cols="3" class="ms-1">{{ $t('name') }}</BCol>
<BCol cols="2">{{ $t('alias') }}</BCol>
<BCol cols="2" :title="$t('projectBranding.newUserToSpaceTooltip')">
{{ $t('projectBranding.newUserToSpace') }}
</BCol>
<BCol cols="3">{{ $t('logo') }}</BCol>
<BCol cols="1">{{ $t('actions') }}</BCol>
</BRow>
<BListGroupItem v-for="item in projectBrandings" :key="item.id">
<project-branding-item
:item="item"
@update:item="handleUpdateItem"
@deleted:item="handleDeleteItem"
/>
</BListGroupItem>
</BListGroup>
</div>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { useAppToast } from '@/composables/useToast'
import gql from 'graphql-tag'
const { toastError } = useAppToast()
const { result, loading, refetch, error } = useQuery(
gql`
query {
projectBrandings {
id
name
alias
description
spaceId
newUserToSpace
logoUrl
}
}
`,
null,
{
fetchPolicy: 'network-only',
},
)
const projectBrandings = ref([])
const isAddButtonDisabled = computed(() => {
return projectBrandings.value.some((item) => item.id === undefined)
})
watch(
result,
() => {
projectBrandings.value = result.value?.projectBrandings || []
},
{ immediate: true },
)
function createEntry() {
projectBrandings.value.push({
id: undefined,
name: '',
alias: '',
description: undefined,
spaceId: undefined,
newUserToSpace: false,
logoUrl: undefined,
})
}
function handleUpdateItem(updatedItem) {
const index = projectBrandings.value.findIndex(
(item) => item.id === updatedItem.id || item.id === undefined,
)
projectBrandings.value.splice(index, 1, updatedItem)
}
function handleDeleteItem(id) {
const index = projectBrandings.value.findIndex((item) => item.id === id)
projectBrandings.value.splice(index, 1)
}
watch(error, () => {
if (error.value) toastError(error.value.message)
})
const animation = computed(() => (loading.value ? 'spin' : ''))
</script>

View File

@ -36,6 +36,11 @@ const routes = [
name: 'federation',
component: () => import('@/pages/FederationVisualize.vue'),
},
{
path: '/projectBranding',
name: 'projectBranding',
component: () => import('@/pages/ProjectBranding.vue'),
},
{
path: '/:catchAll(.*)',
name: 'NotFound',

View File

@ -0,0 +1,14 @@
// TODO: only needed for grace period, before all inputs updated for using veeValidate + yup
export const isLanguageKey = (str) =>
str.match(/^(?!\.)[a-z][a-zA-Z0-9-]*([.][a-z][a-zA-Z0-9-]*)*(?<!\.)$/)
export const translateYupErrorString = (error, t) => {
const type = typeof error
if (type === 'object') {
return t(error.key, error.values)
} else if (type === 'string' && error.length > 0 && isLanguageKey(error)) {
return t(error)
} else {
return error
}
}

View File

@ -5657,6 +5657,11 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.13.1"
property-expr@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@ -6484,6 +6489,11 @@ throttle-debounce@^5.0.0:
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz#ec5549d84e053f043c9fd0f2a6dd892ff84456b1"
integrity sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==
tiny-case@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03"
integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==
tinybench@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
@ -6543,6 +6553,11 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==
tough-cookie@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.0.0.tgz#6b6518e2b5c070cf742d872ee0f4f92d69eac1af"
@ -6608,6 +6623,11 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@ -7145,6 +7165,16 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yup@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/yup/-/yup-1.6.1.tgz#8defcff9daaf9feac178029c0e13b616563ada4b"
integrity sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==
dependencies:
property-expr "^2.0.5"
tiny-case "^1.0.3"
toposort "^2.0.2"
type-fest "^2.19.0"
zen-observable-ts@^0.8.21:
version "0.8.21"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d"

View File

@ -1,5 +1,5 @@
import { ProjectBranding as DbProjectBranding } from '@entity/ProjectBranding'
import { Resolver, Query, Mutation, Arg, Int, Authorized } from 'type-graphql'
import { Resolver, Query, Mutation, Arg, Int, Authorized, ID } from 'type-graphql'
import { ProjectBrandingInput } from '@input/ProjectBrandingInput'
import { ProjectBranding } from '@model/ProjectBranding'
@ -44,13 +44,13 @@ export class ProjectBrandingResolver {
@Mutation(() => ProjectBranding, { nullable: true })
@Authorized([RIGHTS.PROJECT_BRANDING_MUTATE])
async upsertProjectBranding(
@Arg('data') data: ProjectBrandingInput,
@Arg('input') input: ProjectBrandingInput,
): Promise<ProjectBranding | null> {
const projectBranding = data.id
? await DbProjectBranding.findOneOrFail({ where: { id: data.id } })
const projectBranding = input.id
? await DbProjectBranding.findOneOrFail({ where: { id: input.id } })
: new DbProjectBranding()
Object.assign(projectBranding, data)
Object.assign(projectBranding, input)
await projectBranding.save()
return new ProjectBranding(projectBranding)
@ -58,7 +58,7 @@ export class ProjectBrandingResolver {
@Mutation(() => Boolean)
@Authorized([RIGHTS.PROJECT_BRANDING_MUTATE])
async deleteProjectBranding(@Arg('id', () => Int) id: number): Promise<boolean> {
async deleteProjectBranding(@Arg('id', () => ID) id: number): Promise<boolean> {
try {
await DbProjectBranding.delete({ id })
return true