Asynchroniously fetch actual smart annotation object data [SCI-11513]

This commit is contained in:
Martin Artnik 2025-03-11 10:00:34 +01:00
parent 50809dca7d
commit 321733010b
5 changed files with 105 additions and 18 deletions

View file

@ -3,4 +3,10 @@
@apply flex m-auto h-[30px] w-[30px] animate-spin;
background: image-url("sn-loader.svg") center center no-repeat;
}
.sci-loader-inline {
@apply inline-block h-[1.2em] w-[1.2em] animate-spin;
background: image-url("sn-loader.svg") center center no-repeat;
background-size: 1.2em;
}
}

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
class SmartAnnotationsController < ApplicationController
include InputSanitizeHelper
include ActionView::Helpers::TextHelper
@ -14,15 +16,22 @@ class SmartAnnotationsController < ApplicationController
}
end
def redirect
redirect_to redirect_path
def show
if params[:data]
render json: {
name: resource_readable? && resource.name,
type: resource_tag
}
else
redirect_to redirect_path
end
end
def user
user_team_assignment = resource.user_assignments.find_by(assignable: current_team)
render json: {
full_name: resource.full_name,
name: resource.name,
email: resource.email,
avatar_url: user_avatar_absolute_url(resource, :thumb),
info: I18n.t(
@ -36,15 +45,23 @@ class SmartAnnotationsController < ApplicationController
private
def resource
return @resource_class ||= User.find(params[:tag][1..].split('~')[1].base62_decode) if params[:tag][0] == '@'
def sa_tag
@sa_tag ||= params[:tag][1..].split('~')[1]
end
_, resource_tag, resource_id = params[:tag][1..].split('~')
def resource_tag
@resource_tag ||= resource.is_a?(RepositoryRow) ? repository_acronym(resource.repository) : sa_tag
end
def resource
return @resource_class ||= User.find(sa_tag.base62_decode) if params[:tag][0] == '@'
resource_id = params[:tag][1..].split('~').last
resource_id = resource_id.base62_decode
resource_class =
case resource_tag
case sa_tag
when 'prj'
Project
when 'exp'
@ -58,6 +75,16 @@ class SmartAnnotationsController < ApplicationController
@resource ||= resource_class.find(resource_id)
end
def resource_readable?
@resource_readable ||=
case resource
when RepositoryRow
resource.repository.readable_by_user?(current_user)
else
resource.readable_by_user?(current_user)
end
end
def redirect_path
case resource
when Project
@ -70,4 +97,13 @@ class SmartAnnotationsController < ApplicationController
repository_repository_row_path(resource.repository, resource)
end
end
def repository_acronym(repository)
words = repository.name.strip.split
case words.size
when 1 then words[0][0..2]
when 2 then words[0][0..1] + words[1][0]
else words[0..2].map(&:chr).join
end.capitalize
end
end

View file

@ -1,3 +1,7 @@
/* global I18n */
const SA_REGEX = /\[@([^~\]]+)~([0-9a-zA-Z]+)\]|\[#(.*?)~(rep_item|prj|exp|tsk)~([0-9a-zA-Z]+)\]/g;
const escapeHtml = (unsafe) => (
unsafe.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
@ -6,6 +10,17 @@ const escapeHtml = (unsafe) => (
.replaceAll("'", '&#039;')
);
const isInViewport = (element) => {
const rect = element.getBoundingClientRect();
const html = document.documentElement;
return (
rect.top >= 0 && rect.left >= 0
&& rect.bottom <= (window.innerHeight || html.clientHeight)
&& rect.right <= (window.innerWidth || html.clientWidth)
);
};
const renderUserMention = (tag, userName) => {
const safeUserName = escapeHtml(userName);
@ -20,37 +35,63 @@ const renderUserMention = (tag, userName) => {
data-toggle="popover"
data-content=""
data-url="/sa/u?tag=${tag}"
>${safeUserName}</a>`;
><span class="sa-name"><span class="sci-loader-inline"></span></span></a>`;
};
window.renderSmartAnnotations = (text) => (
text.replace(/\[@([^~\]]+)~([0-9a-zA-Z]+)\]|\[#(.*?)~(rep_item|prj|exp|tsk)~([0-9a-zA-Z]+)\]/g, (match, userName, _userId, label, type) => {
text.replace(SA_REGEX, (match, userName, _userId, label, type) => {
const tag = encodeURIComponent(match.slice(1, -1));
if (userName) {
return renderUserMention(tag, userName);
}
const safeLabel = escapeHtml(label);
const safeType = escapeHtml(type);
switch (type) {
case 'rep_item':
return `<a class="sa-link record-info-link" href="/sa?tag=${tag}"><span class="sa-type">INV</span>${safeLabel}</a>`;
return `<a class="sa-link record-info-link" href="/sa?tag=${tag}"><span class="sa-type"></span><span class="sa-name"><span class="sci-loader-inline"></span></span></a>`;
default:
return `<a class="sa-link" href="/sa?tag=${tag}" target="_blank"><span class="sa-type">${safeType}</span>${safeLabel}</a>`;
return `<a class="sa-link" href="/sa?tag=${tag}" target="_blank"><span class="sa-type"></span><span class="sa-name"><span class="sci-loader-inline"></span></span></a>`;
}
})
);
async function fetchSmartAnnotationData(element) {
const response = await fetch(`${element.getAttribute('href') || element.getAttribute('data-url')}&data=true`);
const data = await response.json();
element.querySelector('.sa-name').innerHTML = data.name || `(${I18n.t('general.private')})`;
if (element.querySelector('.sa-type')) {
element.querySelector('.sa-type').innerHTML = data.type;
}
}
window.renderElementSmartAnnotations = (element) => {
if (!element.innerHTML.match(SA_REGEX)) return true;
element.innerHTML = window.renderSmartAnnotations(element.innerHTML);
// Schedule fetch of data for each annotation element when it scrolls into viewport
element.querySelectorAll('.sa-link, .user-tooltip').forEach((el) => {
if (isInViewport(el)) {
fetchSmartAnnotationData(el);
return;
}
const fetchFunction = () => {
fetchSmartAnnotationData(el);
window.removeEventListener('scroll', fetchFunction);
};
window.addEventListener('scroll', fetchFunction);
});
return true;
};
$(document).on('focus', '.user-tooltip', function () {
// using legacy jQuery style, as we need it for tooltips anyway
$(document).on('click', '.user-tooltip', function () {
$.get($(this).data('url'), (data) => {
const content = `<div class="user-name-popover-wrapper">
<div class='col-xs-3'>
@ -62,7 +103,7 @@ $(document).on('focus', '.user-tooltip', function () {
<div class='col-xs-9 pl-3'>
<div class='row'>
<div class='col-xs-12 text-left font-bold'>
${escapeHtml(data.full_name)}
${escapeHtml(data.name)}
</div>
</div>
<div class='row'>

View file

@ -20,6 +20,10 @@ module PermissionCheckableModel
user_role_permissions.include?(permission)
end
def readable_by_user?(user)
permission_granted?(user, "::#{self.class.to_s.split('::').first}Permissions".constantize::READ)
end
private
def load_user_role_permissions(user)

View file

@ -23,7 +23,7 @@ Rails.application.routes.draw do
root 'dashboards#show'
get '/sa', to: 'smart_annotations#redirect'
get '/sa', to: 'smart_annotations#show'
get '/sa/u', to: 'smart_annotations#user'
resources :navigations, only: [] do