feat(webapp): location on registration (#8608)

* feat(webapp): location on registration

* add location name to signup verification, allow location query

* location name can be prompted in regeistration

* default value null for locationName

* Prevent ds-select overflow

* Remove location name from label

* Add margin-bottom to location-select

* group location is not affected by REQUIRE_LOCATION, previous location is shown

* Update webapp/components/Registration/RegistrationSlideCreate.vue

Co-authored-by: Max <maxharz@gmail.com>

* Replace more '16px' by '$space-small' and remove class 'password-strength'

* Add class 'password-strength' again

* property for previous location

---------

Co-authored-by: Maximilian Harz <maxharz@gmail.com>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Moriz Wahl 2025-06-04 15:16:24 +02:00 committed by GitHub
parent 8c30098ac3
commit df4275c5cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 147 additions and 31 deletions

View File

@ -13,6 +13,7 @@ import existingEmailAddress from './helpers/existingEmailAddress'
import generateNonce from './helpers/generateNonce'
import normalizeEmail from './helpers/normalizeEmail'
import { redeemInviteCode } from './inviteCodes'
import { createOrUpdateLocations } from './users/location'
const neode = getNeode()
@ -43,13 +44,16 @@ export default {
}
args.termsAndConditionsAgreedAt = new Date().toISOString()
let { nonce, email, inviteCode } = args
let { nonce, email, inviteCode, locationName } = args
email = normalizeEmail(email)
delete args.nonce
delete args.email
delete args.inviteCode
args.encryptedPassword = await hash(args.password, 10)
delete args.password
delete args.locationName
if (locationName === '') locationName = null
const { driver } = context
const session = driver.session()
@ -68,6 +72,7 @@ export default {
SET user.updatedAt = toString(datetime())
SET user.allowEmbedIframes = false
SET user.showShoutsPublicly = false
SET user.locationName = $locationName
SET email.verifiedAt = toString(datetime())
WITH user
OPTIONAL MATCH (post:Post)-[:IN]->(group:Group)
@ -83,6 +88,7 @@ export default {
nonce,
email,
inviteCode,
locationName,
},
)
const [user] = createUserTransactionResponse.records.map((record) => record.get('user'))
@ -100,6 +106,7 @@ export default {
await redeemInviteCode(context, inviteCode, true)
}
await createOrUpdateLocations('User', user.id, locationName, session)
return user
} catch (e) {
if (e.code === 'Neo.ClientError.Schema.ConstraintValidationFailed')

View File

@ -24,6 +24,7 @@ type Mutation {
about: String
termsAndConditionsAgreedVersion: String!
locale: String
locationName: String = null
): User
AddEmailAddress(email: String!): EmailAddress
VerifyEmailAddress(

View File

@ -428,7 +428,7 @@ export default shield(
Donations: isAuthenticated,
userData: isAuthenticated,
VerifyNonce: allow,
queryLocations: isAuthenticated,
queryLocations: allow,
availableRoles: isAdmin,
Room: isAuthenticated,
Message: isAuthenticated,

View File

@ -9,4 +9,6 @@ BADGES_ENABLED=true
INVITE_LINK_LIMIT=7
NETWORK_NAME="Ocelot.social"
ASK_FOR_REAL_NAME=false
ASK_FOR_REAL_NAME=false
REQUIRE_LOCATION=false

View File

@ -187,3 +187,11 @@ body.dropdown-open {
.dropdown-arrow {
font-size: $font-size-xx-small;
}
/* Prevent ds-select overflow */
.ds-select-value {
max-height: 38px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -96,6 +96,15 @@
</div>
<password-strength class="password-strength" :password="formData.password" />
<!-- location -->
<location-select
v-if="locationRequired"
class="location-select"
v-model="locationName"
:canBeCleared="false"
:showPreviousLocation="false"
/>
<email-display-and-verify :email="sliderData.collectedInputData.email" />
<ds-text>
<input
@ -148,6 +157,7 @@ import EmailDisplayAndVerify from './EmailDisplayAndVerify'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink'
import PasswordForm from '~/components/utils/PasswordFormHelper'
import ShowPassword from '../ShowPassword/ShowPassword.vue'
import LocationSelect from '~/components/Select/LocationSelect'
const threePerEmSpace = '' // unicode u+2004;
@ -159,6 +169,7 @@ export default {
PasswordStrength,
ShowPassword,
SweetalertIcon,
LocationSelect,
},
props: {
sliderData: { type: Object, required: true },
@ -196,6 +207,7 @@ export default {
// TODO: Our styleguide does not support checkmarks.
// Integrate termsAndConditionsConfirmed into `this.formData` once we
// have checkmarks available.
locationName: '',
termsAndConditionsConfirmed: false,
receiveCommunicationAsEmailsEtcConfirmed: false,
showPassword: false,
@ -213,24 +225,16 @@ export default {
this.formData.givenName = ''
}
} else {
this.formData.name = this.sliderData.collectedInputData.name
? this.sliderData.collectedInputData.name
: ''
this.formData.name = this.sliderData.collectedInputData.name || ''
}
this.formData.password = this.sliderData.collectedInputData.password
? this.sliderData.collectedInputData.password
: ''
this.formData.passwordConfirmation = this.sliderData.collectedInputData.passwordConfirmation
? this.sliderData.collectedInputData.passwordConfirmation
: ''
this.termsAndConditionsConfirmed = this.sliderData.collectedInputData
.termsAndConditionsConfirmed
? this.sliderData.collectedInputData.termsAndConditionsConfirmed
: false
this.receiveCommunicationAsEmailsEtcConfirmed = this.sliderData.collectedInputData
.receiveCommunicationAsEmailsEtcConfirmed
? this.sliderData.collectedInputData.receiveCommunicationAsEmailsEtcConfirmed
: false
this.formData.password = this.sliderData.collectedInputData.password || ''
this.formData.passwordConfirmation =
this.sliderData.collectedInputData.passwordConfirmation || ''
this.termsAndConditionsConfirmed =
this.sliderData.collectedInputData.termsAndConditionsConfirmed || false
this.receiveCommunicationAsEmailsEtcConfirmed =
this.sliderData.collectedInputData.receiveCommunicationAsEmailsEtcConfirmed || false
this.locationName = this.sliderData.collectedInputData.locationName || ''
this.sendValidation()
this.sliderData.setSliderValuesCallback(this.validInput, {
@ -238,6 +242,16 @@ export default {
})
},
computed: {
formLocationName() {
// toDo: Mixin or move it to location select component
const isNestedValue =
typeof this.locationName === 'object' && typeof this.locationName.value === 'string'
const isDirectString = typeof this.locationName === 'string'
return isNestedValue ? this.locationName.value : isDirectString ? this.locationName : ''
},
locationRequired() {
return this.$env.REQUIRE_LOCATION
},
askForRealName() {
return this.$env.ASK_FOR_REAL_NAME
},
@ -249,7 +263,8 @@ export default {
this.formData.password.length >= 1 &&
this.formData.password === this.formData.passwordConfirmation &&
this.termsAndConditionsConfirmed &&
this.receiveCommunicationAsEmailsEtcConfirmed
this.receiveCommunicationAsEmailsEtcConfirmed &&
(this.locationRequired ? this.formLocationName : true)
)
},
iconNamePassword() {
@ -266,6 +281,9 @@ export default {
receiveCommunicationAsEmailsEtcConfirmed() {
this.sendValidation()
},
locationName() {
this.sendValidation()
},
},
methods: {
buildName(data) {
@ -276,6 +294,7 @@ export default {
const { password, passwordConfirmation } = this.formData
const name = this.buildName(this.formData)
const { termsAndConditionsConfirmed, receiveCommunicationAsEmailsEtcConfirmed } = this
const locationName = this.formLocationName
this.sliderData.setSliderValuesCallback(this.validInput, {
collectedInputData: {
@ -284,6 +303,7 @@ export default {
passwordConfirmation,
termsAndConditionsConfirmed,
receiveCommunicationAsEmailsEtcConfirmed,
locationName,
},
})
},
@ -299,6 +319,7 @@ export default {
const { email, inviteCode = null, nonce } = this.sliderData.collectedInputData
const termsAndConditionsAgreedVersion = VERSION
const locale = this.$i18n.locale()
try {
this.sliderData.setSliderValuesCallback(null, {
sliderSettings: { buttonLoading: true },
@ -313,6 +334,7 @@ export default {
nonce,
termsAndConditionsAgreedVersion,
locale,
locationName: this.formLocationName,
},
})
this.response = 'success'
@ -378,7 +400,7 @@ export default {
padding-right: 0;
height: $input-height;
margin-bottom: 10px;
margin-bottom: 16px;
margin-bottom: $space-small;
color: $text-color-base;
background: $background-color-disabled;
@ -400,7 +422,7 @@ export default {
.password-field {
position: relative;
padding-top: 16px;
padding-top: $space-small;
border: none;
border-style: none;
appearance: none;
@ -410,6 +432,10 @@ export default {
}
.full-name {
padding-bottom: 16px;
padding-bottom: $space-small;
}
.location-select {
padding-bottom: $space-base;
}
</style>

View File

@ -180,6 +180,7 @@ export default {
passwordConfirmation: null,
termsAndConditionsConfirmed: null,
receiveCommunicationAsEmailsEtcConfirmed: null,
locationName: null,
},
sliderIndex:
this.activePage === null ? 0 : sliders.findIndex((el) => el.name === this.activePage),

View File

@ -1,16 +1,31 @@
import { mount } from '@vue/test-utils'
import LocationSelect from './LocationSelect'
import { queryLocations } from '~/graphql/location'
const localVue = global.localVue
const propsData = { value: 'nowhere' }
let wrapper
const queryMock = jest.fn().mockResolvedValue({
data: {
queryLocations: [
{
place_name: 'Hamburg, Germany',
place_id: 'xxx',
},
],
},
})
const mocks = {
$t: jest.fn((string) => string),
$i18n: {
locale: () => 'en',
},
$apollo: {
query: queryMock,
},
}
describe('LocationSelect', () => {
@ -25,18 +40,28 @@ describe('LocationSelect', () => {
wrapper = Wrapper()
})
it('renders the label', () => {
expect(wrapper.find('label.ds-input-label').exists()).toBe(true)
it('renders the label with previous location by default', () => {
expect(wrapper.find('label.ds-input-label').text()).toBe('settings.data.labelCity — nowhere')
})
it('renders the select', () => {
expect(wrapper.find('.ds-select').exists()).toBe(true)
})
it('renders the clearLocationName button', () => {
it('renders the clearLocationName button by default', () => {
expect(wrapper.find('.base-button').exists()).toBe(true)
})
it('calls apollo with given value', () => {
expect(queryMock).toBeCalledWith({
query: queryLocations(),
variables: {
place: 'nowhere',
lang: 'en',
},
})
})
describe('clearLocationName button click', () => {
beforeEach(() => {
wrapper.find('.base-button').trigger('click')
@ -48,5 +73,27 @@ describe('LocationSelect', () => {
expect(wrapper.emitted().input[0]).toEqual([''])
})
})
describe('canBeCleared is false', () => {
beforeEach(() => {
propsData.canBeCleared = false
wrapper = Wrapper()
})
it('does not show clear location name button', () => {
expect(wrapper.find('.base-button').exists()).toBe(false)
})
})
describe('showPreviousLocation is false', () => {
beforeEach(() => {
propsData.showPreviousLocation = false
wrapper = Wrapper()
})
it('does not show the previous location', () => {
expect(wrapper.find('.ds-input-label').text()).toBe('settings.data.labelCity')
})
})
})
})

View File

@ -1,8 +1,7 @@
<template>
<div>
<label class="ds-input-label">
{{ `${$t('settings.data.labelCity')}` }}
<span v-if="locationName">{{ ` ${locationName}` }}</span>
{{ `${$t('settings.data.labelCity')}` + locationNameLabelAddOnOldName }}
</label>
<ds-select
id="city"
@ -15,7 +14,7 @@
@input.native="handleCityInput"
/>
<base-button
v-if="locationName !== ''"
v-if="locationName !== '' && canBeCleared"
icon="close"
ghost
size="small"
@ -36,6 +35,16 @@ export default {
value: {
required: true,
},
canBeCleared: {
type: Boolean,
required: false,
default: true,
},
showPreviousLocation: {
type: Boolean,
required: false,
default: true,
},
},
async created() {
const result = await this.requestGeoData(this.locationName)
@ -54,6 +63,9 @@ export default {
locationName() {
return typeof this.value === 'object' ? this.value.value : this.value
},
locationNameLabelAddOnOldName() {
return this.locationName !== '' && this.showPreviousLocation ? ' — ' + this.locationName : ''
},
},
watch: {
currentValue() {

View File

@ -38,6 +38,7 @@ const options = {
INVITE_LINK_LIMIT: process.env.INVITE_LINK_LIMIT || 7,
NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social',
ASK_FOR_REAL_NAME: process.env.ASK_FOR_REAL_NAME === 'true' || false,
REQUIRE_LOCATION: process.env.REQUIRE_LOCATION === 'true' || false,
}
const language = {

View File

@ -9,6 +9,7 @@ export const SignupVerificationMutation = gql`
$about: String
$termsAndConditionsAgreedVersion: String!
$locale: String
$locationName: String
) {
SignupVerification(
nonce: $nonce
@ -19,6 +20,7 @@ export const SignupVerificationMutation = gql`
about: $about
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locale: $locale
locationName: $locationName
) {
id
name

View File

@ -15,7 +15,11 @@
:placeholder="$t('settings.data.namePlaceholder')"
/>
<ds-input id="slug" model="slug" icon="at" :label="$t('settings.data.labelSlug')" />
<location-select v-model="formData.locationName" />
<location-select
class="location-selet"
v-model="formData.locationName"
:canBeCleared="!$env.REQUIRE_LOCATION"
/>
<!-- eslint-enable vue/use-v-on-exact -->
<ds-input
id="about"
@ -76,6 +80,7 @@ export default {
translate: this.$t,
})
return {
locationName: { required: this.$env.REQUIRE_LOCATION },
name: { required: true, min: 3 },
...uniqueSlugForm.formSchema,
}
@ -122,4 +127,8 @@ export default {
.location-hint {
margin-top: -$space-x-small - $space-xxx-small - $space-xxx-small;
}
.location-selet {
margin-bottom: $space-small;
}
</style>