'$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'); } }