User: Verify and improve Terms & Conditions and GDPR - refs #5121
parent
9b0e6fc6df
commit
bfa7400628
@ -0,0 +1,53 @@ |
||||
<template> |
||||
<div class="base-editor"> |
||||
<label v-if="title" :for="editorId">{{ title }}</label> |
||||
<TinyEditor |
||||
:id="editorId" |
||||
:model-value="modelValue" |
||||
:init="editorConfig" |
||||
:required="required" |
||||
@update:model-value="updateValue" |
||||
@input="updateValue" |
||||
/> |
||||
<p v-if="helpText" class="help-text">{{ helpText }}</p> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup> |
||||
import { ref, computed, watch } from 'vue' |
||||
const props = defineProps({ |
||||
editorId: String, |
||||
modelValue: String, |
||||
required: Boolean, |
||||
editorConfig: Object, |
||||
title: String, |
||||
helpText: String |
||||
}) |
||||
const emit = defineEmits(['update:modelValue']) |
||||
const updateValue = (value) => { |
||||
emit('update:modelValue', value) |
||||
document.getElementById(props.editorId).value = value |
||||
} |
||||
const defaultEditorConfig = { |
||||
skin_url: '/build/libs/tinymce/skins/ui/oxide', |
||||
content_css: '/build/libs/tinymce/skins/content/default/content.css', |
||||
branding: false, |
||||
relative_urls: false, |
||||
height: 280, |
||||
toolbar_mode: 'sliding', |
||||
file_picker_callback: browser, |
||||
autosave_ask_before_unload: true, |
||||
plugins: [ |
||||
'fullpage advlist autolink lists link image charmap print preview anchor', |
||||
'searchreplace visualblocks code fullscreen', |
||||
'insertdatetime media table paste wordcount emoticons', |
||||
], |
||||
toolbar: 'undo redo | bold italic underline strikethrough | ...', |
||||
} |
||||
const editorConfig = computed(() => ({ |
||||
...defaultEditorConfig, |
||||
...props.editorConfig |
||||
})) |
||||
function browser(callback, value, meta) { |
||||
} |
||||
</script> |
||||
@ -0,0 +1,18 @@ |
||||
export default { |
||||
path: '/resources/terms-conditions', |
||||
meta: { requiresAuth: true }, |
||||
name: 'TermsConditions', |
||||
component: () => import('../views/terms/TermsLayout.vue'), |
||||
children: [ |
||||
{ |
||||
name: 'TermsConditionsList', |
||||
path: '', |
||||
component: () => import('../views/terms/TermsList.vue') |
||||
}, |
||||
{ |
||||
name: 'TermsConditionsEdit', |
||||
path: 'edit', |
||||
component: () => import('../views/terms/TermsEdit.vue') |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,20 @@ |
||||
import makeService from './api' |
||||
import { ENTRYPOINT } from "../config/entrypoint" |
||||
|
||||
const legalExtensions = { |
||||
async findAllAvailable() { |
||||
const url = new URL(`${ENTRYPOINT}languages`) |
||||
url.searchParams.append("available", "true") |
||||
try { |
||||
const response = await fetch(url.toString()) |
||||
if (!response.ok) { |
||||
throw new Error('Network response was not ok') |
||||
} |
||||
return await response.json() |
||||
} catch (error) { |
||||
console.error('Error fetching available languages:', error) |
||||
throw error |
||||
} |
||||
}, |
||||
} |
||||
export default makeService('languages', legalExtensions) |
||||
@ -0,0 +1,37 @@ |
||||
import makeService from './api'; |
||||
import { ENTRYPOINT } from "../config/entrypoint" |
||||
|
||||
const legalExtensions = { |
||||
async findAllByLanguage(languageId) { |
||||
const params = new URLSearchParams({ |
||||
languageId: languageId, |
||||
'order[version]': 'desc' |
||||
}); |
||||
return fetch(`${ENTRYPOINT}legals?${params.toString()}`); |
||||
}, |
||||
async saveOrUpdateLegal(payload) { |
||||
console.log('Saving or updating legal terms'); |
||||
return fetch(`/legal/save`, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json' |
||||
}, |
||||
body: JSON.stringify(payload) |
||||
}); |
||||
}, |
||||
async fetchExtraFields(termId = null) { |
||||
try { |
||||
const url = termId ? `/legal/extra-fields?termId=${termId}` : `/legal/extra-fields`; |
||||
const response = await fetch(url); |
||||
if (!response.ok) { |
||||
throw new Error('Network response was not ok'); |
||||
} |
||||
return await response.json(); |
||||
} catch (error) { |
||||
console.error('Error loading extra fields:', error); |
||||
throw error; |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
export default makeService('legals', legalExtensions); |
||||
@ -0,0 +1,196 @@ |
||||
<template> |
||||
<div class="terms-edit-view mb-8"> |
||||
|
||||
<Message severity="info" icon="pi pi-send" :closable="false" class="mt-5"> |
||||
{{ t('Display a Terms & Conditions statement on the registration page, require visitor to accept the T&C to register') }} |
||||
</Message> |
||||
|
||||
<BaseToolbar showTopBorder> |
||||
<div class="flex justify-between w-full items-center"> |
||||
<BaseDropdown |
||||
class="w-96 mb-0" |
||||
:options="languages" |
||||
v-model="selectedLanguage" |
||||
optionLabel="name" |
||||
placeholder="Select a language" |
||||
inputId="language-dropdown" |
||||
label="Language" |
||||
name="language" |
||||
/> |
||||
<BaseButton :label="t('Load')" @click="loadTermsByLanguage" icon="search" type="button" class="ml-4"/> |
||||
<BaseButton :label="t('All versions')" type="secondary" @click="backToList" icon="back" class="ml-4" /> |
||||
</div> |
||||
</BaseToolbar> |
||||
|
||||
<div v-if="termsLoaded"> |
||||
<form @submit.prevent="saveTerms"> |
||||
<BaseEditor |
||||
:editorId="'item_content'" |
||||
v-model="termData.content" |
||||
:title="t('Personal Data Collection')" |
||||
> |
||||
<template #help-text> |
||||
<p>{{ t('Why do we collect this data?') }}</p> |
||||
</template> |
||||
</BaseEditor> |
||||
|
||||
|
||||
<BaseRadioButtons |
||||
:options="typeOptions" |
||||
v-model="termData.type" |
||||
name="termsType" |
||||
:title="t('Type of Terms')" |
||||
/> |
||||
|
||||
<Dialog v-model:visible="dialogVisible" :style="{ width: '50vw' }" :header="t('Preview')" :modal="true"> |
||||
<div v-html="previewContent" /> |
||||
</Dialog> |
||||
|
||||
<!-- Extra fields --> |
||||
<div v-for="field in extraFields" :key="field.id" class="extra-field"> |
||||
<component :is="getFieldComponent(field.type)" v-bind="field.props" @update:modelValue="field.props.modelValue = $event"> |
||||
<template v-if="field.type === 'editor'" #help-text> |
||||
<p>{{ field.props.helpText }}</p> |
||||
</template> |
||||
</component> |
||||
</div> |
||||
|
||||
<BaseTextArea |
||||
id="changes" |
||||
label="Explain changes" |
||||
v-model="termData.changes" |
||||
/> |
||||
|
||||
<div class="form-actions"> |
||||
<BaseButton label="Back" type="secondary" @click="backToList" icon="back" class="mr-4" /> |
||||
<BaseButton label="Preview" type="primary" @click="previewTerms" icon="search" class="mr-4" /> |
||||
<BaseButton label="Save" type="success" isSubmit icon="save" class="mr-4" /> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup> |
||||
import { onMounted, ref, watch } from "vue" |
||||
import { useRouter } from "vue-router" |
||||
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue" |
||||
import BaseButton from "../../components/basecomponents/BaseButton.vue" |
||||
import BaseTextArea from "../../components/basecomponents/BaseTextArea.vue" |
||||
import Message from "primevue/message" |
||||
import BaseDropdown from "../../components/basecomponents/BaseDropdown.vue" |
||||
import BaseRadioButtons from "../../components/basecomponents/BaseRadioButtons.vue" |
||||
import BaseInputText from "../../components/basecomponents/BaseInputText.vue" |
||||
import BaseEditor from "../../components/basecomponents/BaseEditor.vue" |
||||
import { useI18n } from "vue-i18n" |
||||
import languageService from "../../services/languageService" |
||||
import legalService from "../../services/legalService" |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const router = useRouter() |
||||
const languages = ref([]) |
||||
const selectedLanguage = ref(null) |
||||
const termsLoaded = ref(false) |
||||
const termData = ref({ |
||||
language: '', |
||||
content: '', |
||||
type: '0', |
||||
changes: '', |
||||
}) |
||||
const dialogVisible = ref(false) |
||||
const previewContent = ref('') |
||||
const typeOptions = ref([ |
||||
{ label: 'HTML', value: '0' }, |
||||
{ label: 'Page Link', value: '1' } |
||||
]) |
||||
const loadTermsByLanguage = async () => { |
||||
if (!selectedLanguage.value) return |
||||
termsLoaded.value = false |
||||
try { |
||||
const response = await legalService.findAllByLanguage(selectedLanguage.value.id) |
||||
if (response.ok) { |
||||
const data = await response.json() |
||||
const latestTerm = data['hydra:member'].length ? data['hydra:member'][0] : null |
||||
termData.value = latestTerm ? { |
||||
id: latestTerm.id, |
||||
content: latestTerm.content, |
||||
type: latestTerm.type.toString(), |
||||
changes: latestTerm.changes, |
||||
} : { |
||||
content: '', |
||||
type: '0', |
||||
changes: '', |
||||
} |
||||
extraFields.value = await legalService.fetchExtraFields(latestTerm ? latestTerm.id : null) |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading terms:', error) |
||||
} finally { |
||||
termsLoaded.value = true |
||||
} |
||||
} |
||||
const saveTerms = async () => { |
||||
const payload = { |
||||
lang: selectedLanguage.value.id, |
||||
content: termData.value.content, |
||||
type: termData.value.type.toString(), |
||||
changes: termData.value.changes, |
||||
extraFields: {}, |
||||
} |
||||
extraFields.value.forEach(field => { |
||||
payload.extraFields[field.id] = field.props.modelValue |
||||
}) |
||||
try { |
||||
const response = await legalService.saveOrUpdateLegal(payload) |
||||
if (response.ok) { |
||||
await router.push({ name: 'TermsConditionsList' }) |
||||
} else { |
||||
console.error('Error saving or updating legal terms:', response.statusText) |
||||
} |
||||
} catch (error) { |
||||
console.error('Error when making request:', error) |
||||
} |
||||
} |
||||
const previewTerms = () => { |
||||
previewContent.value = termData.value.content |
||||
dialogVisible.value = true |
||||
} |
||||
const closePreview = () => { |
||||
dialogVisible.value = false |
||||
} |
||||
function backToList() { |
||||
router.push({ name: 'TermsConditionsList' }) |
||||
} |
||||
|
||||
const extraFields = ref([]) |
||||
|
||||
function getFieldComponent(type) { |
||||
const componentMap = { |
||||
text: BaseInputText, |
||||
select: BaseDropdown, |
||||
editor: BaseEditor, |
||||
// Add more mappings as needed |
||||
} |
||||
return componentMap[type] || 'div' |
||||
} |
||||
|
||||
watch(selectedLanguage, () => { |
||||
termsLoaded.value = false |
||||
}) |
||||
onMounted(async () => { |
||||
try { |
||||
const response = await languageService.findAll() |
||||
if (!response.ok) { |
||||
throw new Error('Network response was not ok') |
||||
} |
||||
const data = await response.json() |
||||
languages.value = data['hydra:member'].map(lang => ({ |
||||
name: lang.englishName, |
||||
id: lang.id, |
||||
})) |
||||
} catch (error) { |
||||
console.error('Error loading languages:', error) |
||||
} |
||||
}) |
||||
</script> |
||||
@ -0,0 +1,20 @@ |
||||
<template> |
||||
<div class="terms-layout"> |
||||
<header> |
||||
<h1>{{ t('Terms and conditions') }}</h1> |
||||
</header> |
||||
|
||||
<main> |
||||
<router-view /> |
||||
</main> |
||||
|
||||
<footer> |
||||
</footer> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup> |
||||
import { useI18n } from "vue-i18n" |
||||
const { t } = useI18n() |
||||
|
||||
</script> |
||||
@ -0,0 +1,111 @@ |
||||
<template> |
||||
<div class="terms-list-view mt-4"> |
||||
<BaseToolbar> |
||||
<BaseButton |
||||
:label="t('Edit Terms and Conditions')" |
||||
icon="edit" |
||||
type="primary" |
||||
@click="editTerms" |
||||
/> |
||||
</BaseToolbar> |
||||
|
||||
<Message severity="warn" :closable="false"> |
||||
{{ t('You should create the Term and Conditions for all the available languages.') }} |
||||
</Message> |
||||
|
||||
<DataTable :value="terms" :loading="isLoading"> |
||||
<Column field="version" header="Version"></Column> |
||||
<Column field="language" header="Language"></Column> |
||||
|
||||
<Column header="Content"> |
||||
<template #body="slotProps"> |
||||
<div v-html="slotProps.data.content"></div> |
||||
</template> |
||||
</Column> |
||||
|
||||
<Column field="changes" header="Changes"></Column> |
||||
<Column field="typeLabel" header="Type"></Column> |
||||
<Column field="date" header="Date"> |
||||
<template #body="slotProps"> |
||||
{{ formatDate(slotProps.data.date) }} |
||||
</template> |
||||
</Column> |
||||
</DataTable> |
||||
</div> |
||||
</template> |
||||
|
||||
<script setup> |
||||
import { onMounted, ref } from "vue" |
||||
import DataTable from "primevue/datatable" |
||||
import Column from "primevue/column" |
||||
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue" |
||||
import BaseButton from "../../components/basecomponents/BaseButton.vue" |
||||
import { useI18n } from "vue-i18n" |
||||
import { useRouter } from "vue-router" |
||||
import Message from "primevue/message" |
||||
import languageService from "../../services/languageService" |
||||
import legalService from "../../services/legalService" |
||||
|
||||
const { t } = useI18n() |
||||
const router = useRouter() |
||||
const terms = ref([]) |
||||
const isLoading = ref(false) |
||||
async function fetchLanguageName(languageId) { |
||||
try { |
||||
const response = await languageService.find("/api/languages/" + languageId) |
||||
if (response.ok) { |
||||
const languageData = await response.json() |
||||
return languageData.originalName |
||||
} |
||||
} catch (error) { |
||||
console.error("Error loading language details:", error) |
||||
} |
||||
return null |
||||
} |
||||
|
||||
onMounted(async () => { |
||||
isLoading.value = true |
||||
try { |
||||
const response = await legalService.findAll() |
||||
if (response.ok) { |
||||
const data = await response.json() |
||||
terms.value = await Promise.all(data['hydra:member'].map(async (term) => { |
||||
const languageName = await fetchLanguageName(term.languageId) |
||||
return { |
||||
...term, |
||||
language: languageName, |
||||
typeLabel: getTypeLabel(term.type), |
||||
} |
||||
})) |
||||
} else { |
||||
console.error("The request to the API was not successful:", response.statusText) |
||||
} |
||||
} catch (error) { |
||||
console.error("Error loading legal terms:", error) |
||||
} finally { |
||||
isLoading.value = false |
||||
} |
||||
}) |
||||
function getTypeLabel(typeValue) { |
||||
const typeMap = { |
||||
'0': t('HTML'), |
||||
'1': t('Page Link'), |
||||
} |
||||
return typeMap[typeValue] || 'Unknown' |
||||
} |
||||
|
||||
function formatDate(timestamp) { |
||||
const date = new Date(timestamp * 1000) |
||||
const day = date.getDate().toString().padStart(2, '0') |
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0') |
||||
const year = date.getFullYear() |
||||
const hours = date.getHours().toString().padStart(2, '0') |
||||
const minutes = date.getMinutes().toString().padStart(2, '0') |
||||
const seconds = date.getSeconds().toString().padStart(2, '0') |
||||
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}` |
||||
} |
||||
|
||||
function editTerms() { |
||||
router.push({ name: 'TermsConditionsEdit' }) |
||||
} |
||||
</script> |
||||
@ -0,0 +1,192 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/* For licensing terms, see /license.txt */ |
||||
|
||||
namespace Chamilo\CoreBundle\Controller; |
||||
|
||||
use Chamilo\CoreBundle\Entity\Legal; |
||||
use Chamilo\CoreBundle\Repository\LegalRepository; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use ExtraField; |
||||
use ExtraFieldValue; |
||||
use LegalManager; |
||||
use Symfony\Component\HttpFoundation\JsonResponse; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Annotation\Route; |
||||
|
||||
#[Route('/legal')] |
||||
class LegalController |
||||
{ |
||||
#[Route('/save', name: 'chamilo_core_legal_save', methods: ['POST'])] |
||||
public function saveLegal( |
||||
Request $request, |
||||
EntityManagerInterface $entityManager, |
||||
LegalRepository $legalRepository |
||||
): Response { |
||||
$data = json_decode($request->getContent(), true); |
||||
|
||||
$lang = $data['lang'] ?? null; |
||||
$content = $data['content'] ?? null; |
||||
$type = isset($data['type']) ? (int)$data['type'] : null; |
||||
$changes = $data['changes'] ?? ''; |
||||
$extraFields = $data['extraFields'] ?? []; |
||||
|
||||
$lastLegal = $legalRepository->findLastConditionByLanguage($lang); |
||||
$extraFieldValue = new ExtraFieldValue('terms_and_condition'); |
||||
|
||||
$newVersionRequired = !$lastLegal || $lastLegal->getContent() !== $content || $this->hasExtraFieldsChanged($extraFieldValue, $lastLegal->getId(), $extraFields); |
||||
$typeUpdateRequired = $lastLegal && $lastLegal->getType() !== $type; |
||||
|
||||
$legalToUpdate = $lastLegal; |
||||
if ($newVersionRequired) { |
||||
$legal = new Legal(); |
||||
$legal->setLanguageId($lang); |
||||
$legal->setContent($content); |
||||
$legal->setType($type); |
||||
$legal->setChanges($changes); |
||||
$legal->setDate(time()); |
||||
$version = $lastLegal ? $lastLegal->getVersion() + 1 : 1; |
||||
$legal->setVersion($version); |
||||
|
||||
$entityManager->persist($legal); |
||||
$legalToUpdate = $legal; |
||||
} elseif ($typeUpdateRequired) { |
||||
$lastLegal->setType($type); |
||||
$lastLegal->setChanges($changes); |
||||
} |
||||
|
||||
$entityManager->flush(); |
||||
|
||||
if ($newVersionRequired || $typeUpdateRequired) { |
||||
$this->updateExtraFields($extraFieldValue, $legalToUpdate->getId(), $extraFields); |
||||
} |
||||
|
||||
return new Response('Term and condition saved or updated successfully', Response::HTTP_OK); |
||||
} |
||||
|
||||
#[Route('/extra-fields', name: 'chamilo_core_get_extra_fields')] |
||||
public function getExtraFields(Request $request): JsonResponse |
||||
{ |
||||
|
||||
$extraField = new ExtraField('terms_and_condition'); |
||||
$types = LegalManager::getTreatmentTypeList(); |
||||
|
||||
foreach ($types as $variable => $name) { |
||||
$label = 'PersonalData'.ucfirst($name).'Title'; |
||||
$params = [ |
||||
'variable' => $variable, |
||||
'display_text' => $label, |
||||
'value_type' => ExtraField::FIELD_TYPE_TEXTAREA, |
||||
'default_value' => '', |
||||
'visible' => true, |
||||
'changeable' => true, |
||||
'filter' => true, |
||||
'visible_to_self' => true, |
||||
'visible_to_others' => true, |
||||
]; |
||||
$extraField->save($params); |
||||
} |
||||
|
||||
$termId = $request->query->get('termId'); |
||||
$extraData = $extraField->get_handler_extra_data($termId ?? 0); |
||||
$extraFieldsDefinition = $extraField->get_all(); |
||||
$fieldsData = $extraField->getExtraFieldsData( |
||||
$extraData, |
||||
true, |
||||
$extraFieldsDefinition |
||||
); |
||||
|
||||
$prefix = 'extra_'; |
||||
$extraFields = []; |
||||
foreach ($fieldsData as $field) { |
||||
$fieldType = $this->mapFieldType($field['type']); |
||||
$extraField = [ |
||||
'id' => $prefix.$field['variable'], |
||||
'type' => $fieldType, |
||||
'props' => [ |
||||
'title' => $field['title'], |
||||
'defaultValue' => $field['defaultValue'], |
||||
], |
||||
]; |
||||
|
||||
switch ($fieldType) { |
||||
case 'editor': |
||||
$extraField['props']['editorId'] = $prefix.$field['variable']; |
||||
$extraField['props']['modelValue'] = $field['value'] ?? ''; |
||||
$extraField['props']['helpText'] = 'Specific help text for ' . $field['title']; |
||||
break; |
||||
case 'text': |
||||
$extraField['props']['label'] = $field['title']; |
||||
$extraField['props']['modelValue'] = $field['value'] ?? ''; |
||||
break; |
||||
case 'select': |
||||
$extraField['props']['label'] = $field['title']; |
||||
$extraField['props']['options'] = []; |
||||
$extraField['props']['modelValue'] = $field['value'] ?? ''; |
||||
break; |
||||
} |
||||
|
||||
$extraFields[] = $extraField; |
||||
} |
||||
|
||||
return new JsonResponse($extraFields); |
||||
} |
||||
|
||||
/** |
||||
* Checks if the extra field values have changed. |
||||
* |
||||
* This function compares the new values for extra fields against the old ones to determine |
||||
* if there have been any changes. It is useful for triggering events or updates only when |
||||
* actual changes to data occur. |
||||
*/ |
||||
private function hasExtraFieldsChanged(ExtraFieldValue $extraFieldValue, int $legalId, array $newValues): bool |
||||
{ |
||||
$oldValues = $extraFieldValue->getAllValuesByItem($legalId); |
||||
$oldValues = array_column($oldValues, 'value', 'variable'); |
||||
|
||||
foreach ($newValues as $key => $newValue) { |
||||
if (isset($oldValues[$key]) && $newValue != $oldValues[$key]) { |
||||
return true; |
||||
} elseif (!isset($oldValues[$key])) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* Updates the extra fields with new values for a specific item. |
||||
*/ |
||||
private function updateExtraFields(ExtraFieldValue $extraFieldValue, int $legalId, array $values): void |
||||
{ |
||||
$values['item_id'] = $legalId; |
||||
$extraFieldValue->saveFieldValues($values); |
||||
} |
||||
|
||||
/** |
||||
* Maps an integer representing a field type to its corresponding string value. |
||||
*/ |
||||
private function mapFieldType(int $type): string |
||||
{ |
||||
switch ($type) { |
||||
case ExtraField::FIELD_TYPE_TEXT: |
||||
return 'text'; |
||||
case ExtraField::FIELD_TYPE_TEXTAREA: |
||||
return 'editor'; |
||||
case ExtraField::FIELD_TYPE_SELECT_MULTIPLE: |
||||
case ExtraField::FIELD_TYPE_DATE: |
||||
case ExtraField::FIELD_TYPE_DATETIME: |
||||
case ExtraField::FIELD_TYPE_DOUBLE_SELECT: |
||||
case ExtraField::FIELD_TYPE_RADIO: |
||||
// Manage as needed |
||||
break; |
||||
case ExtraField::FIELD_TYPE_SELECT: |
||||
return 'select'; |
||||
} |
||||
|
||||
return 'text'; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue