mirror of https://github.com/nextcloud/server.git
Merge pull request #43488 from nextcloud/fix/use-nc-components-account-property-section
fix: Use nextcloud-vue components for personal info settingspull/43592/head
commit
8134559bba
@ -0,0 +1,181 @@
|
||||
<!--
|
||||
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
|
||||
-
|
||||
- @author Christopher Ng <chrng8@gmail.com>
|
||||
- @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Fragment>
|
||||
<NcActionButton v-for="federationScope in federationScopes"
|
||||
:key="federationScope.name"
|
||||
:close-after-click="true"
|
||||
:disabled="!supportedScopes.includes(federationScope.name)"
|
||||
:name="federationScope.displayName"
|
||||
type="radio"
|
||||
:value="federationScope.name"
|
||||
:model-value="scope"
|
||||
@update:modelValue="changeScope">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="federationScope.icon" />
|
||||
</template>
|
||||
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
|
||||
</NcActionButton>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { Fragment } from 'vue-frag'
|
||||
|
||||
import {
|
||||
ACCOUNT_PROPERTY_READABLE_ENUM,
|
||||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
|
||||
PROFILE_READABLE_ENUM,
|
||||
PROPERTY_READABLE_KEYS_ENUM,
|
||||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
|
||||
SCOPE_ENUM, SCOPE_PROPERTY_ENUM,
|
||||
UNPUBLISHED_READABLE_PROPERTIES,
|
||||
} from '../../../constants/AccountPropertyConstants.js'
|
||||
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
|
||||
import { handleError } from '../../../utils/handlers.js'
|
||||
|
||||
const {
|
||||
federationEnabled,
|
||||
lookupServerUploadEnabled,
|
||||
} = loadState('settings', 'accountParameters', {})
|
||||
|
||||
export default {
|
||||
name: 'FederationControlActions',
|
||||
|
||||
components: {
|
||||
Fragment,
|
||||
NcActionButton,
|
||||
NcIconSvgWrapper,
|
||||
},
|
||||
|
||||
props: {
|
||||
readable: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
|
||||
},
|
||||
additional: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
additionalValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
handleAdditionalScopeChange: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
readableLowerCase: this.readable.toLocaleLowerCase(),
|
||||
initialScope: this.scope,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
federationScopes() {
|
||||
return Object.values(SCOPE_PROPERTY_ENUM)
|
||||
},
|
||||
|
||||
supportedScopes() {
|
||||
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
|
||||
|
||||
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
|
||||
return scopes
|
||||
}
|
||||
|
||||
if (federationEnabled) {
|
||||
scopes.push(SCOPE_ENUM.FEDERATED)
|
||||
}
|
||||
|
||||
if (lookupServerUploadEnabled) {
|
||||
scopes.push(SCOPE_ENUM.PUBLISHED)
|
||||
}
|
||||
|
||||
return scopes
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async changeScope(scope) {
|
||||
this.$emit('update:scope', scope)
|
||||
|
||||
if (!this.additional) {
|
||||
await this.updatePrimaryScope(scope)
|
||||
} else {
|
||||
await this.updateAdditionalScope(scope)
|
||||
}
|
||||
},
|
||||
|
||||
async updatePrimaryScope(scope) {
|
||||
try {
|
||||
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async updateAdditionalScope(scope) {
|
||||
try {
|
||||
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
|
||||
this.handleResponse({
|
||||
scope,
|
||||
status: responseData.ocs?.meta?.status,
|
||||
})
|
||||
} catch (e) {
|
||||
this.handleResponse({
|
||||
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
handleResponse({ scope, status, errorMessage, error }) {
|
||||
if (status === 'ok') {
|
||||
this.initialScope = scope
|
||||
} else {
|
||||
this.$emit('update:scope', this.initialScope)
|
||||
handleError(error, errorMessage)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @author Ferdinand Thiessen <opensource@fthiessen.de>
|
||||
*
|
||||
* @license AGPL-3.0-or-later
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import type { User } from '@nextcloud/cypress'
|
||||
import { handlePasswordConfirmation } from './usersUtils.ts'
|
||||
|
||||
let user: User
|
||||
|
||||
enum Visibility {
|
||||
Private = 'Private',
|
||||
Local = 'Local',
|
||||
Federated = 'Federated',
|
||||
Public = 'Published'
|
||||
}
|
||||
|
||||
const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated]
|
||||
|
||||
/**
|
||||
* Get the input connected to a specific label
|
||||
* @param label The content of the label
|
||||
*/
|
||||
const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`))
|
||||
|
||||
/**
|
||||
* Get the property visibility button
|
||||
* @param property The property to which to look for the button
|
||||
*/
|
||||
const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`)
|
||||
|
||||
/**
|
||||
* Validate a specifiy visibility is set for a property
|
||||
* @param property The property
|
||||
* @param active The active visibility
|
||||
*/
|
||||
const validateActiveVisibility = (property: string, active: Visibility) => {
|
||||
getVisibilityButton(property)
|
||||
.should('have.attr', 'aria-label')
|
||||
.and('match', new RegExp(`current scope is ${active}`, 'i'))
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', active)
|
||||
.should('have.attr', 'aria-pressed', 'true')
|
||||
|
||||
// close menu
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific visibility for a property
|
||||
* @param property The property
|
||||
* @param active The visibility to set
|
||||
*/
|
||||
const setActiveVisibility = (property: string, active: Visibility) => {
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', active)
|
||||
.click({ force: true })
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check that setting all visibilities on a property is possible
|
||||
* @param property The property to test
|
||||
* @param defaultVisibility The default visibility of that property
|
||||
* @param allowedVisibility Visibility that is allowed and need to be checked
|
||||
*/
|
||||
const checkSettingsVisibility = (property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) => {
|
||||
getVisibilityButton(property)
|
||||
.scrollIntoView()
|
||||
|
||||
validateActiveVisibility(property, defaultVisibility)
|
||||
|
||||
allowedVisibility.forEach((active) => {
|
||||
setActiveVisibility(property, active)
|
||||
|
||||
cy.reload()
|
||||
getVisibilityButton(property).scrollIntoView()
|
||||
|
||||
validateActiveVisibility(property, active)
|
||||
})
|
||||
|
||||
// TODO: Fix this in vue library then enable this test again
|
||||
/* // Test that not allowed options are disabled
|
||||
ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => {
|
||||
getVisibilityButton(property)
|
||||
.click()
|
||||
cy.get('ul[role="dialog"')
|
||||
.contains('button', disabled)
|
||||
.should('exist')
|
||||
.and('have.attr', 'disabled', 'true')
|
||||
}) */
|
||||
}
|
||||
|
||||
const genericProperties = ['Location', 'X (formerly Twitter)', 'Fediverse']
|
||||
const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About']
|
||||
|
||||
describe('Settings: Change personal information', { testIsolation: true }, () => {
|
||||
|
||||
before(() => {
|
||||
// ensure we can set locale and language
|
||||
cy.runOccCommand('config:system:delete force_language')
|
||||
cy.runOccCommand('config:system:delete force_locale')
|
||||
})
|
||||
|
||||
after(() => {
|
||||
cy.runOccCommand('config:system:set force_language --value en')
|
||||
cy.runOccCommand('config:system:set force_locale --value en_US')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createRandomUser().then(($user) => {
|
||||
user = $user
|
||||
cy.modifyUser(user, 'language', 'en')
|
||||
cy.modifyUser(user, 'locale', 'en_US')
|
||||
cy.login($user)
|
||||
cy.visit('/settings/user')
|
||||
})
|
||||
cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting')
|
||||
})
|
||||
|
||||
it('Can dis- and enable the profile', () => {
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('h2', user.userId).should('be.visible')
|
||||
|
||||
cy.visit('/settings/user')
|
||||
cy.contains('Enable profile').click()
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
|
||||
cy.contains('h2', 'Profile not found').should('be.visible')
|
||||
|
||||
cy.visit('/settings/user')
|
||||
cy.contains('Enable profile').click()
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
|
||||
cy.contains('h2', user.userId).should('be.visible')
|
||||
})
|
||||
|
||||
it('Can change language', () => {
|
||||
cy.intercept('GET', /settings\/user/).as('reload')
|
||||
inputForLabel('Language').scrollIntoView()
|
||||
inputForLabel('Language').type('Ned')
|
||||
cy.contains('li[role="option"]', 'Nederlands')
|
||||
.click()
|
||||
cy.wait('@reload')
|
||||
|
||||
// expect language changed
|
||||
inputForLabel('Taal').scrollIntoView()
|
||||
cy.contains('section', 'Help met vertalen')
|
||||
})
|
||||
|
||||
it('Can change locale', () => {
|
||||
cy.intercept('GET', /settings\/user/).as('reload')
|
||||
cy.clock(new Date(2024, 0, 10))
|
||||
|
||||
// Default is US
|
||||
cy.contains('section', '01/10/2024')
|
||||
|
||||
inputForLabel('Locale').scrollIntoView()
|
||||
inputForLabel('Locale').type('German')
|
||||
cy.contains('li[role="option"]', 'German (Germany')
|
||||
.click()
|
||||
cy.wait('@reload')
|
||||
|
||||
// expect locale changed
|
||||
inputForLabel('Locale').scrollIntoView()
|
||||
cy.contains('section', '10.01.2024')
|
||||
})
|
||||
|
||||
it('Can set primary email and change its visibility', () => {
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Email').type('foo bar')
|
||||
inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
|
||||
// handle valid input
|
||||
inputForLabel('Email').type('{selectAll}hello@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', 'hello@example.com')
|
||||
|
||||
checkSettingsVisibility(
|
||||
'Email',
|
||||
Visibility.Federated,
|
||||
// It is not possible to set it as private
|
||||
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
|
||||
)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com')
|
||||
})
|
||||
|
||||
it('Can delete primary email', () => {
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
inputForLabel('Email').type('{selectAll}hello@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check after reload
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', 'hello@example.com')
|
||||
|
||||
// delete email
|
||||
cy.get('button[aria-label="Remove primary email"]').click({ force: true })
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check after reload
|
||||
cy.reload()
|
||||
inputForLabel('Email').should('have.value', '')
|
||||
})
|
||||
|
||||
it('Can set and delete additional emails', () => {
|
||||
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
|
||||
// we need a primary email first
|
||||
cy.contains('label', 'Email').scrollIntoView()
|
||||
inputForLabel('Email').type('{selectAll}primary@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// add new email
|
||||
cy.get('button[aria-label="Add additional email"]')
|
||||
.click()
|
||||
|
||||
// without any value we should not be able to add a second additional
|
||||
cy.get('button[aria-label="Add additional email"]').should('be.disabled')
|
||||
|
||||
// fill the first additional
|
||||
inputForLabel('Additional email address 1')
|
||||
.type('1@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// add second additional email
|
||||
cy.get('button[aria-label="Add additional email"]')
|
||||
.click()
|
||||
|
||||
// fill the second additional
|
||||
inputForLabel('Additional email address 2')
|
||||
.type('2@example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
cy.wait('@submitSetting')
|
||||
|
||||
// check the content is saved
|
||||
cy.reload()
|
||||
inputForLabel('Additional email address 1')
|
||||
.should('have.value', '1@example.com')
|
||||
inputForLabel('Additional email address 2')
|
||||
.should('have.value', '2@example.com')
|
||||
|
||||
// delete the first
|
||||
cy.get('button[aria-label="Options for additional email address 1"]')
|
||||
.click({ force: true })
|
||||
cy.contains('button[role="menuitem"]', 'Delete email')
|
||||
.click({ force: true })
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.reload()
|
||||
inputForLabel('Additional email address 1')
|
||||
.should('have.value', '2@example.com')
|
||||
})
|
||||
|
||||
it('Can set Full name and change its visibility', () => {
|
||||
cy.contains('label', 'Full name').scrollIntoView()
|
||||
// handle valid input
|
||||
inputForLabel('Full name').type('{selectAll}Jane Doe')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Full name').should('have.value', 'Jane Doe')
|
||||
|
||||
checkSettingsVisibility(
|
||||
'Full name',
|
||||
Visibility.Federated,
|
||||
// It is not possible to set it as private
|
||||
ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
|
||||
)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('h2', 'Jane Doe').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can set Phone number and its visibility', () => {
|
||||
cy.contains('label', 'Phone number').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Phone number').type('foo bar')
|
||||
inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
|
||||
// handle valid input
|
||||
inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701')
|
||||
inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Phone number').should('have.value', '+498972101099701')
|
||||
|
||||
checkSettingsVisibility('Phone number')
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.get('a[href="tel:+498972101099701"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Can set Website and change its visibility', () => {
|
||||
cy.contains('label', 'Website').scrollIntoView()
|
||||
// Check invalid input
|
||||
inputForLabel('Website').type('foo bar')
|
||||
inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
|
||||
// handle valid input
|
||||
inputForLabel('Website').type('{selectAll}http://example.com')
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel('Website').should('have.value', 'http://example.com')
|
||||
|
||||
checkSettingsVisibility('Website')
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains('http://example.com').should('be.visible')
|
||||
})
|
||||
|
||||
// Check generic properties that allow any visibility and any value
|
||||
genericProperties.forEach((property) => {
|
||||
it(`Can set ${property} and change its visibility`, () => {
|
||||
const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
|
||||
cy.contains('label', property).scrollIntoView()
|
||||
inputForLabel(property).type(uniqueValue)
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel(property).should('have.value', uniqueValue)
|
||||
|
||||
checkSettingsVisibility(property)
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains(uniqueValue).should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
// Check non federated properties - those where we need special configuration and only support local visibility
|
||||
nonfederatedProperties.forEach((property) => {
|
||||
it(`Can set ${property} and change its visibility`, () => {
|
||||
const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
|
||||
cy.contains('label', property).scrollIntoView()
|
||||
inputForLabel(property).type(uniqueValue)
|
||||
handlePasswordConfirmation(user.password)
|
||||
|
||||
cy.wait('@submitSetting')
|
||||
cy.reload()
|
||||
inputForLabel(property).should('have.value', uniqueValue)
|
||||
|
||||
checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local])
|
||||
|
||||
// check it is visible on the profile
|
||||
cy.visit(`/u/${user.userId}`)
|
||||
cy.contains(uniqueValue).should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue