'$refresh',
'tagifyFocus',
'tagifyBlur',
'saveCard'=> 'save',
];
protected function rules()
{
return [
'newTagsArray' => 'array',
'newTag' => 'array',
'newTag.name' => Rule::when(
function ($input) {
// Check if newTag is not an empty array
return count($input['newTag']) > 0;
},
array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule'),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->sessionLanguageOk && !$this->sessionLanguageIgnored) {
$locale = app()->getLocale();
$localeName = \Locale::getDisplayName($locale, $locale);
$fail(
__('Is this :locale? Please confirm here below', [
'locale' => $localeName
])
);
}
},
]
),
),
'newTagCategory' => Rule::when(
function ($input) {
if (count($input['newTag']) > 0 && $this->translationVisible === true && $this->translateRadioButton == 'input') {
return true;
}
if (count($input['newTag']) > 0 && $this->translationVisible === false) {
return true;
}
},
['required', 'int'],
),
'selectTagTranslation' => Rule::when(
function ($input) {
// Check if existing tag translation is selected
return $this->translationVisible === true && $this->translateRadioButton == 'select';
},
['required', 'int'],
),
'inputTagTranslation' => 'array',
'inputTagTranslation.name' => Rule::when(
fn ($input) => $this->translationVisible === true && $this->translateRadioButton === 'input',
array_merge(
timebank_config('tags.name_rule'),
timebank_config('tags.exists_in_current_locale_rule'),
[
'sometimes',
function ($attribute, $value, $fail) {
if (!$this->transLanguageOk && !$this->transLanguageIgnored) {
$baseLocale = $this->selectTranslationLanguage;
$locale = \Locale::getDisplayName($baseLocale, $baseLocale);
$fail(__('Is this :locale? Please confirm here below', [
'locale' => $locale
]));
}
},
function ($attribute, $value, $fail) {
$existsInTransLationLanguage = DB::table('taggable_tags')
->join('taggable_locales', 'taggable_tags.tag_id', '=', 'taggable_locales.taggable_tag_id')
->where('taggable_locales.locale', $this->selectTranslationLanguage)
->where(function ($query) use ($value) {
$query->where('taggable_tags.name', $value)
->orWhere('taggable_tags.normalized', $value);
})
->exists();
if ($existsInTransLationLanguage) {
$fail(__('This tag already exists.'));
}
},
]
),
[]
),
];
}
public function mount($label = null)
{
if ($label === null) {
$label = __('Activities or skills you offer to other ' . platform_users());
}
$this->label = $label;
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$owner->cleanTaggables();
$this->checkTranslationAllowed();
$this->checkTranslationPossible();
$this->getSuggestions();
$this->getInitialTags();
$this->getLanguageDetector();
$this->dispatch('load');
}
protected function getSuggestions()
{
$suggestions = (new Tag())->localTagArray(app()->getLocale());
$this->suggestions = collect($suggestions)->map(function ($value) {
return app()->getLocale() == 'de' ? $value : StringHelper::DutchTitleCase($value);
});
}
protected function getInitialTags()
{
$this->initTagIds = getActiveProfile()->tags()->get()->pluck('tag_id');
$this->initTagsArray = TaggableLocale::whereIn('taggable_tag_id', $this->initTagIds)
->select('taggable_tag_id', 'locale', 'updated_by_user')
->get()
->toArray();
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($this->initTagIds));
$tags = $translatedTags->map(function ($item, $key) {
return [
'original_tag_id' => $item['original_tag_id'],
'tag_id' => $item['tag_id'],
'value' => $item['locale'] == App::getLocale() ? $item['tag'] : $item['tag'] . ' (' . strtoupper($item['locale']) . ')',
'readonly' => false, // Tags are never readonly so the remove button is always visible
'class' => $item['locale'] == App::getLocale() ? '' : 'tag-foreign-locale', // Mark foreign-locale tags with a class for diagonal stripe styling
'locale' => $item['locale'],
'category' => $item['category'],
'category_path' => $item['category_path'],
'category_color' => $item['category_color'],
'title' => $item['category_path'], // 'title' is used by Tagify script for text that shows on hover
'style' =>
'--tag-bg:' .
tailwindColorToHex($item['category_color'] . '-400') .
'; --tag-text-color:#000' . // #111827 is gray-900
'; --tag-hover:' .
tailwindColorToHex($item['category_color'] . '-200'), // 'style' is used by Tagify script for background color, tailwindColorToHex is a helper function in app/Helpers/StyleHelper.php
];
});
$tags = $tags->sortBy('category_color')->values();
$this->initTagsArrayTranslated = $tags->toArray();
$this->tagsArray = json_encode($tags->toArray());
}
public function checkSessionLanguage()
{
// Ensure the language detector is initialized
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->newTag['name']);
if ($detectedLanguage === session('locale')) {
$this->sessionLanguageOk = true;
// No need to ignore language detection when session locale is detected
$this->sessionLanguageIgnored = false;
} else {
$this->sessionLanguageOk = false;
}
$this->validateOnly('newTag.name');
}
public function checkTransLanguage()
{
// Ensure the language detector is initialized
$this->getLanguageDetector();
$detectedLanguage = $this->langDetector->detectSimple($this->inputTagTranslation['name']);
if ($detectedLanguage === $this->selectTranslationLanguage) {
$this->transLanguageOk = true;
// No need to ignore language detection when base locale is detected
$this->transLanguageIgnored = false;
} else {
$this->transLanguageOk = false;
}
$this->validateOnly('inputTagTranslation.name');
}
public function checkTranslationAllowed()
{
// Check if translations are allowed based on config and profile type
$allowTranslations = timebank_config('tags.allow_tag_transations_for_non_admins', false);
$profileType = getActiveProfileType();
// If config is false, only admins can add translations
if (!$allowTranslations) {
$this->translationAllowed = ($profileType === 'admin');
} else {
// If config is true, all profile types can add translations
$this->translationAllowed = true;
}
}
public function checkTranslationPossible()
{
// Check if profile is capable to do any translations
$countNonBaseLanguages = getActiveProfile()->languages()->where('lang_code', '!=', timebank_config('base_language'))->count();
if ($countNonBaseLanguages === 0 && app()->getLocale() === timebank_config('base_language')) {
$this->translationPossible = false;
}
}
protected function getLanguageDetector()
{
if (!$this->langDetector) {
$this->langDetector = new \Text_LanguageDetect();
$this->langDetector->setNameMode(2); // iso language code with 2 characters
}
return $this->langDetector;
}
public function updatedNewTagName()
{
$this->resetErrorBag('newTag.name');
// Check if name is the profiles's session's locale
$this->checkSessionLanguage();
// Only fall back to initTagsArray if newTagsArray has not been set yet,
// to preserve any tags the user already added before opening the create modal
if ($this->newTagsArray === null) {
$this->newTagsArray = collect($this->initTagsArray);
}
}
public function updatedSessionLanguageIgnored()
{
if (!$this->sessionLanguageIgnored) {
$this->checkSessionLanguage();
}
// Revalidate the newTag.name field
$this->validateOnly('newTag.name');
}
public function updatedTransLanguageIgnored()
{
if (!$this->transLanguageIgnored) {
$this->checkTransLanguage();
} else {
$this->resetErrorBag('inputTagTranslation.name');
}
}
public function updatedSelectTranslationLanguage()
{
$this->selectTagTranslation = [];
// Suggest related tags in the selected translation language
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
}
public function updatedNewTagCategory()
{
$this->categoryColor = collect($this->categoryOptions)
->firstWhere('category_id', $this->newTagCategory)['color'] ?? 'gray';
$this->selectTagTranslation = [];
// Suggest related tags in the selected translation language
$this->translationOptions = $this->getTranslationOptions($this->selectTranslationLanguage);
$this->resetErrorBag('inputTagTranslationCategory');
}
public function updatedInputTagTranslationName()
{
$this->validateOnly('inputTagTranslation.name');
}
public function updatedTagsArray()
{
// Prevent save during updating cycle of the tagsArray
$this->saveDisabled = true;
$this->newTagsArray = collect(json_decode($this->tagsArray, true));
$localesToCheck = [app()->getLocale(), '']; // Only current locale and tags without locale should be checked for any new tag keywords
$newTagsArrayLocal = $this->newTagsArray->whereIn('locale', $localesToCheck);
// map suggestion to lower case for search normalization of the $newEntries
$suggestions = collect($this->suggestions)->map(function ($value) {
return strtolower($value);
});
// Retrieve new tag entries not present in suggestions
$newEntries = $newTagsArrayLocal->filter(function ($newItem) use ($suggestions) {
return !$suggestions->contains(strtolower($newItem['value']));
});
// Add a new tag modal if there are new entries
if (count($newEntries) > 0) {
$this->newTag['name'] = app()->getLocale() == 'de' ? $newEntries->flatten()->first() : ucfirst($newEntries->flatten()->first());
$this->categoryOptions = Category::where('type', Tag::class)
->get()
->map(function ($category) {
// Include all attributes, including appended ones
return [
'category_id' => $category->id,
'name' => ucfirst($category->translation->name ?? ''), // Use the appended 'translation' attribute
'description' => $category->relatedPathExSelfTranslation ?? '', // Appended attribute
'color' => $category->relatedColor ?? 'gray',
];
})
->sortBy('name')
->values();
// Open the create tag modal
$this->modalVisible = true;
// For proper validation, this needs to be done after the netTag.name input of the modal is visible
$this->checkSessionLanguage();
} else {
$newEntries = false;
// Enable save button as updating cycle tagsArray is finished by now
$this->saveDisabled = false;
}
$this->checkChangesTagsArray();
}
public function checkChangesTagsArray()
{
// Check if tagsArray has been changed, to display 'unsaved changes' message next to save button
$initTagIds = collect($this->initTagIds);
$newTagIds = $this->newTagsArray->pluck('tag_id');
$diffFromNew = $newTagIds->diff($initTagIds);
$diffFromInit = $initTagIds->diff($newTagIds);
if ($diffFromNew->isNotEmpty() || $diffFromInit->isNotEmpty()) {
$this->tagsArrayChanged = true;
} else {
$this->tagsArrayChanged = false;
}
}
// When tagify raises focus, disable the save button
public function tagifyFocus()
{
$this->saveDisabled = true;
}
// When tagify looses focus, enable the save button
public function tagifyBlur()
{
$this->saveDisabled = false;
}
public function renderedModalVisible()
{
// Enable save button as updating cycle tagsArray is finished by now
$this->saveDisabled = false;
}
public function updatedTranslationVisible()
{
if ($this->translationVisible && $this->translationAllowed) {
$this->updatedNewTagCategory();
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
// Get all languages of the profile with good competence
$this->translationLanguages = $profile
->languages()
->wherePivot('competence', 1)
->where('lang_code', '!=', app()->getLocale())
->get()
->map(function ($language) {
$language->name = trans($language->name); // Map the name property to a translation key
return $language;
});
// Make sure that always the base language is included even if the profile does not has it as a competence 1
if (!$this->translationLanguages->contains('lang_code', 'en')) {
$transLanguage = Language::where('lang_code', timebank_config('base_language'))->first();
if ($transLanguage) {
$transLanguage->name = trans($transLanguage->name); // Map the name property to a translation key
// Add the base language to the translationLanguages collection
$this->translationLanguages = collect($this->translationLanguages)
->push($transLanguage);
}
// Set the default selection to base language
if (app()->getLocale() != timebank_config('base_language')) {
$this->selectTranslationLanguage = timebank_config('base_language');
}
}
}
}
public function updatedTranslateRadioButton()
{
if ($this->translateRadioButton === 'select') {
$this->inputDisabled = true;
$this->dispatch('disableInput');
} elseif ($this->translateRadioButton === 'input') {
$this->inputDisabled = false;
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
}
$this->resetErrorBag('selectTagTranslation');
$this->resetErrorBag('inputTagTranslation.name');
$this->resetErrorBag('newTagCategory');
}
public function updatedSelectTagTranslation()
{
if ($this->selectTagTranslation) {
$this->categoryColor = Tag::find($this->selectTagTranslation)->categories->first()->relatedColor ?? 'gray';
$this->translateRadioButton = 'select';
$this->dispatch('disableInput');
}
}
public function updatedInputTagTranslation()
{
$this->translateRadioButton = 'input';
$this->inputDisabled = false;
// $this->dispatch('disableSelect'); // Script inside view skills-form.blade.php
$this->checkTransLanguage();
}
/**
* Updates the visibility of the modal. If the modal becomes invisible, dispatches the 'remove' event to remove the last value of the tags array on the front-end.
*/
public function updatedModalVisible()
{
if ($this->modalVisible == false) {
$this->dispatch('remove'); // Removes last value of the tagsArray on front-end only
$this->dispatch('reinitializeComponent');
}
}
/**
* Retrieves a list of related tags based on the specified category and locale.
* Get all translation options in the choosen locale,
* but exclude all tags already have a similar context in the current $appLocal.
*
* @param int|null $category The ID of the category to filter related tags. If null, all tags in the locale are suggested.
* @param string|null $locale The locale to use for tag names. If not provided, the application's current locale is used.
*
* @return \Illuminate\Support\Collection A collection of tags containing 'tag_id' and 'name' keys, sorted by name.
*/
public function getTranslationOptions($locale)
{
$appLocale = app()->getLocale();
// 1) Get all context_ids used by tags that match app()->getLocale()
$contextIdsInAppLocale = DB::table('taggable_locale_context')
->whereIn('tag_id', function ($query) use ($appLocale) {
$query->select('taggable_tag_id')
->from('taggable_locales')
->where('locale', $appLocale);
})
->pluck('context_id');
// 2) Exclude tags that share these context_ids
$tags = Tag::with(['locale', 'contexts.category'])
->whereHas('locale', function ($query) use ($locale) {
$query->where('locale', $locale);
})
->whereNotIn('tag_id', function ($subquery) use ($contextIdsInAppLocale) {
$subquery->select('tag_id')
->from('taggable_locale_context')
->whereIn('context_id', $contextIdsInAppLocale);
})
->get();
// 3) Build the options array. Adjust the name logic to your preference.
$options = $tags->map(function ($tag) use ($locale) {
$category = optional($tag->contexts->first())->category;
$description = optional(optional($category)->translation)->name ?? '';
return [
'tag_id' => $tag->tag_id,
'name' => $locale == 'de' ? $tag->name : StringHelper::DutchTitleCase($tag->normalized),
'description' => $description,
];
})->sortBy('name')->values();
return $options;
}
/**
* Cancels the creation of a new tag by resetting error messages,
* clearing input fields, hiding translation and modal visibility,
* and resetting tag arrays to their initial state.
*/
public function cancelCreateTag()
{
$this->resetErrorBag();
$this->newTag = [];
$this->newTagCategory = null;
$this->translationVisible = false;
$this->translateRadioButton = null;
$this->sessionLanguageOk = false;
$this->sessionLanguageIgnored = false;
$this->transLanguageOk = false;
$this->transLanguageIgnored = false;
$this->categoryColor = 'gray';
$this->selectTagTranslation = null;
$this->inputTagTranslation = [];
$this->inputDisabled = true;
// $this->newTagsArray = collect($this->initTagsArray);
// $this->tagsArray = json_encode($this->initTagsArray);
// Remove last value of the tagsArray
$tagsArray = is_string($this->tagsArray) ? json_decode($this->tagsArray, true) : $this->tagsArray;
array_pop($tagsArray);
$this->tagsArray = json_encode($tagsArray);
// Check of there were also other unsaved new tags in the tagsArray
$hasNoTagId = false;
if (is_array($tagsArray)) {
$this->tagsArrayChanged = count(array_filter($tagsArray, function ($tag) {
return !array_key_exists('tag_id', $tag) || $tag['tag_id'] === null;
})) > 0;
} else {
$this->tagsArrayChanged = false;
}
$this->modalVisible = false;
$this->updatedModalVisible();
}
/**
* Handles the save button of the create tag modal.
*
* Creates a new tag for the currently active profile and optionally
* associates it with a category or base-language translation.
*
* @return void
*/
public function createTag()
{
// TODO: MAKE A TRANSACTION
$this->validate();
$this->resetErrorBag();
// Format strings to correct case
$this->newTag['name'] = app()->getLocale() == 'de' ? $this->newTag['name'] : StringHelper::DutchTitleCase($this->newTag['name']);
$name = $this->newTag['name'];
$normalized = call_user_func(config('taggable.normalizer'), $name);
// Create the tag and attach the owner and context
$tag = Tag::create([
'name' => $name,
'normalized' => $normalized,
]);
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$owner->tagById($tag->tag_id);
$context = [
'category_id' => $this->newTagCategory,
'updated_by_user' => Auth::guard('web')->user()->id, // use the logged user, not the active profile
];
if ($this->translationVisible) {
if ($this->translateRadioButton === 'select') {
// Attach an existing context in the base language to the new tag. See timebank_config('base_language')
// Note that the category_id and updated_by_user is not updated when selecting an existing context
$tagContext = Tag::find($this->selectTagTranslation)
->contexts()
->first();
$tag->contexts()->attach($tagContext->id);
} elseif ($this->translateRadioButton === 'input') {
// Create a new context for the new tag
$tagContext = $tag->contexts()->create($context);
// Create a new translation of the tag
$this->inputTagTranslation['name'] = $this->selectTranslationLanguage == 'de' ? $this->inputTagTranslation['name'] : StringHelper::DutchTitleCase($this->inputTagTranslation['name']);
// $owner->tag($this->inputTagTranslation['name']);
$nameTranslation = $this->inputTagTranslation['name'];
$normalizedTranslation = call_user_func(config('taggable.normalizer'), $nameTranslation);
$locale = ['locale' => $this->selectTranslationLanguage ];
// Create the translation tag with the locale and attach the context
$tagTranslation = Tag::create([
'name' => $nameTranslation,
'normalized' => $normalizedTranslation,
]);
$tagTranslation->locale()->create($locale);
$tagTranslation->contexts()->attach($tagContext->id);
// The translation now has been recorded. Next, detach owner from this translation as only the locale tag should be attached to the owner
$owner->untagById([$tagTranslation->tag_id]);
// Also clean up owner's tags that have similar context but have different locale. Only the tag in owner's app()->getLocale() should remain in db.
$owner->cleanTaggables(); // In TaggableWithLocale trait
}
} else {
// Create a new context for the new tag without translation
$tagContext = $tag->contexts()->create($context);
}
$this->modalVisible = false;
$this->saveDisabled = false;
// Attach the new collection of tags to the active profile
$this->save();
$this->tagsArrayChanged = false;
// Dispatch the SendEmailNewTag job
SendEmailNewTag::dispatch($tag->tag_id);
}
/**
* Saves the newTagsArray: attaches the current tags to the profile model.
* Ignores the tags that are marked read-only (no app locale and no base language locale).
* Dispatches notification on success or error.
*
* @return void
*/
public function save()
{
if ($this->saveDisabled === false) {
if ($this->newTagsArray) {
try {
// Use a transaction for saving skill tags
DB::transaction(function () {
// Make sure we can count newTag for conditional validation rules
if ($this->newTag === null) {
$this->newTag = [];
}
$owner = getActiveProfile();
if (!$owner) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
\App\Helpers\ProfileAuthorizationHelper::authorize($owner);
$this->validate();
// try {
// $this->validate();
// } catch (ValidationException $e) {
// // Dump all validation errors to the log or screen
// logger()->error('Validation failed', $e->errors());
// dd($e->errors()); // or use dump() if you prefer
// }
$this->resetErrorBag();
$initTags = collect($this->initTagsArray)->pluck('taggable_tag_id');
$newTagsArray = collect($this->newTagsArray);
$newTags = $newTagsArray
->where('tag_id', null)
->pluck('value')->toArray();
$owner->tag($newTags);
$remainingTags = $this->newTagsArray
->where('tag_id')
->pluck('tag_id')->toArray();
$removedTags = $initTags->diff($remainingTags)->toArray();
$owner->untagById($removedTags);
// Finaly clean up taggables table: remove duplicate contexts and any orphaned taggables
// In TaggableWithLocale trait
$owner->cleanTaggables();
$owner->touch(); // Observer catches this and reindexes search index
// WireUI notification
$this->notification()->success($title = __('Your have updated your profile successfully!'));
});
// end of transaction
} catch (Throwable $e) {
// WireUI notification
// TODO!: create event to send error notification to admin
$this->notification([
'title' => __('Update failed!'),
'description' => __('Sorry, your data could not be saved!') . '
' . __('Our team has ben notified about this error. Please try again later.') . '
' . $e->getMessage(),
'icon' => 'error',
'timeout' => 100000,
]);
}
$this->tagsArrayChanged = false;
$this->dispatch('saved');
$this->forgetCachedSkills();
$this->cacheSkills();
$this->initTagsArray = [];
$this->newTag = null;
$this->newTagsArray = null;
$this->newTagCategory = null;
$this->dispatch('refreshComponent');
$this->dispatch('reinitializeTagify');
$this->dispatch('reloadPage');
}
}
}
public function forgetCachedSkills()
{
// Get the profile type (user / organization) from the session and convert to lowercase
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType'))));
// Get the supported locales from the config
$locales = config('app.supported_locales', [app()->getLocale()]);
// Iterate over each locale and forget the cache
foreach ($locales as $locale) {
Cache::forget('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . $locale);
}
}
public function cacheSkills()
{
$profileType = strtolower(basename(str_replace('\\', '/', session('activeProfileType')))); // Get the profile type (user / organization) from the session and convert to lowercase
$skillsCache = Cache::remember('skills-' . $profileType . '-' . session('activeProfileId') . '-lang-' . app()->getLocale(), now()->addDays(7), function () {
// remember cache for 7 days
$tagIds = session('activeProfileType')::find(session('activeProfileId'))->tags->pluck('tag_id');
$translatedTags = collect((new Tag())->translateTagIdsWithContexts($tagIds, App::getLocale(), App::getFallbackLocale())); // Translate to app locale, if not available to fallback locale, if not available do not translate
$skills = $translatedTags->map(function ($item, $key) {
return [
'original_tag_id' => $item['original_tag_id'],
'tag_id' => $item['tag_id'],
'name' => $item['tag'],
'foreign' => $item['locale'] == App::getLocale() ? false : true, // Mark all tags in a foreign language read-only, as users need to switch locale to edit/update/etc foreign tags
'locale' => $item['locale'],
'category' => $item['category'],
'category_path' => $item['category_path'],
'category_color' => $item['category_color'],
];
});
$skills = collect($skills);
return $skills;
});
$this->tagsArray = json_encode($skillsCache->toArray());
}
public function render()
{
return view('livewire.main-page.skills-card-full');
}
}