Merge branch 'decoupling-settings-page' of https://github.com/biosistemika/scinote-web into zd_SCI_1854

This commit is contained in:
zmagod 2018-01-09 10:03:02 +01:00
commit 0d7c116de1
18 changed files with 252 additions and 142 deletions

View file

@ -109,14 +109,14 @@
// Activity feed modal in main navigation menu // Activity feed modal in main navigation menu
var activityModal = $('#activity-modal'); var activityModal = $('#activity-modal');
var activityModalBody = activityModal.find('.modal-body'); var activityModalBody = activityModal.find('.modal-body');
var initMoreBtn = function() { var initMoreBtn = function() {
activityModalBody.find('.btn-more-activities') activityModalBody.find('.btn-more-activities')
.on('ajax:success', function(e, data) { .on('ajax:success', function(e, data) {
$(data.html).insertBefore($(this).parents('li')); $(data.html).insertBefore($(this).parents('li'));
$(this).attr('href', data.next_url); if(data.more_url) {
if (data.activities_number < data.per_page) { $(this).attr('href', data.more_url);
$(this).remove(); } else {
$(this).remove();
} }
}); });
}; };

View file

@ -1,47 +1,43 @@
class ActivitiesController < ApplicationController class ActivitiesController < ApplicationController
include ActivityHelper include ActivityHelper
before_action :load_vars
def index def index
@per_page = Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT
@activities = current_user.last_activities(@last_activity_id,
@per_page + 1)
@overflown = @activities.length > @per_page
@activities = current_user.last_activities(@last_activity_id,
@per_page)
# Whether to hide date labels
@hide_today = params.include? :from
@day = @last_activity.present? ?
days_since_1970(@last_activity.created_at) :
days_since_1970(DateTime.current + 30.days)
more_url = url_for(activities_url(format: :json,
from: @activities.last.id)) if @overflown
respond_to do |format| respond_to do |format|
format.json { format.json do
render :json => { render json: {
per_page: @per_page, more_url: local_vars.fetch(:more_activities_url),
activities_number: @activities.length, html: render_to_string(
next_url: more_url, partial: 'index.html.erb', locals: local_vars
html: render_to_string({ )
partial: 'index.html.erb',
locals: {
more_activities_url: more_url,
hide_today: @hide_today,
day: @day
}
})
} }
} end
end end
end end
def load_vars private
@last_activity_id = params[:from].to_i || 0
@last_activity = Activity.find_by_id(@last_activity_id) def local_vars
page = (params[:page] || 1).to_i
activities = current_user.last_activities
.page(page)
.per(Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT)
unless activities.last_page?
more_url = url_for(
activities_url(
format: :json,
page: page + 1,
last_activity: activities.last.id
)
)
end
# send last activity date of the previus batch
previous_activity = Activity.find_by_id(params[:last_activity])
previus_date = previous_activity.created_at.to_date if previous_activity
{
activities: activities,
more_activities_url: more_url,
page: page,
previous_activity_created_at: previus_date
}
end end
end end

View file

@ -14,11 +14,17 @@ module ClientApi
private private
def activities_vars def activities_vars
last_activity_id = params[:from].to_i || 0 page = (params[:page] || 1).to_i
per_page = Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT activities = current_user
activities = current_user.last_activities(last_activity_id, per_page + 1) .last_activities
more = activities.length > per_page .page(page)
{ activities: activities, more: more } .per(Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT)
{
activities: activities,
page: page,
more: !activities.last_page?,
timezone: current_user.time_zone
}
end end
end end
end end

View file

@ -56,4 +56,15 @@ module ActivityHelper
def days_since_1970(dt) def days_since_1970(dt)
dt.to_i / 86400 dt.to_i / 86400
end end
def calculate_previous_date(activities,
index,
previus_batch_last_activitiy_date)
if index == 1 && !activities.first_page?
return previus_batch_last_activitiy_date
end
activity = activities[index - 1]
return activity.created_at.to_date if activity
Date.new(1901, 1, 1)
end
end end

View file

@ -1,10 +1,10 @@
// @flow // @flow
import React from "react"; import React from "react";
import Moment from "react-moment"; import type { Node } from "react";
import { FormattedDate, FormattedMessage } from "react-intl";
import { Tooltip, OverlayTrigger } from "react-bootstrap"; import { Tooltip, OverlayTrigger } from "react-bootstrap";
import styled from "styled-components"; import styled from "styled-components";
import { FormattedMessage } from "react-intl";
import { import {
WHITE_COLOR, WHITE_COLOR,
@ -14,8 +14,12 @@ import {
import { NAME_TRUNCATION_LENGTH } from "../../../config/constants/numeric"; import { NAME_TRUNCATION_LENGTH } from "../../../config/constants/numeric";
type InputActivity = {
activity: Activity
}
const StyledLi = styled.li` const StyledLi = styled.li`
border-radius: .25em; border-radius: 0.25em;
margin-bottom: 1em; margin-bottom: 1em;
background-color: ${WHITE_COLOR}; background-color: ${WHITE_COLOR};
border: 1px solid ${COLOR_CONCRETE}; border: 1px solid ${COLOR_CONCRETE};
@ -24,8 +28,8 @@ const TimeSpan = styled.span`
min-width: 150px; min-width: 150px;
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
border-top-left-radius: .25em; border-top-left-radius: 0.25em;
border-bottom-left-radius: .25em; border-bottom-left-radius: 0.25em;
border: 3px solid ${BORDER_GRAY_COLOR}; border: 3px solid ${BORDER_GRAY_COLOR};
background-color: ${BORDER_GRAY_COLOR}; background-color: ${BORDER_GRAY_COLOR};
padding-left: 10px; padding-left: 10px;
@ -37,52 +41,57 @@ const TextSpan = styled.span`
display: table-cell; display: table-cell;
padding: 3px 10px; padding: 3px 10px;
text-align: justify; text-align: justify;
` `;
function truncatedTooltip(id, text) {
function truncatedTooltip(id: string, text: any): Node {
return ( return (
<OverlayTrigger overlay={( <OverlayTrigger
<Tooltip id={id}> overlay={<Tooltip id={id}>{text}</Tooltip>}
{text} placement="bottom"
</Tooltip> >
)} placement="bottom"> <span>{text.substring(0, NAME_TRUNCATION_LENGTH)}...</span>
<span>
{text.substring(0, NAME_TRUNCATION_LENGTH)}...
</span>
</OverlayTrigger> </OverlayTrigger>
); );
} }
function taskPath(activity) { function taskPath(activity: Activity): Node {
return ( return (
<span>&nbsp; <span>
[&nbsp;<FormattedMessage id="general.project" />:&nbsp; &nbsp; [&nbsp;<FormattedMessage id="general.project" />:&nbsp;
{activity.project.length > NAME_TRUNCATION_LENGTH ? ( {activity.project.length > NAME_TRUNCATION_LENGTH ? (
truncatedTooltip('activity_modal.long_project_tooltip', activity.project) truncatedTooltip(
):( "activity_modal.long_project_tooltip",
activity.project
)
) : (
<span>{activity.project}</span> <span>{activity.project}</span>
)},&nbsp; )},&nbsp;
<FormattedMessage id="general.task" />:&nbsp; <FormattedMessage id="general.task" />:&nbsp;
{activity.task.length > NAME_TRUNCATION_LENGTH ? ( {activity.task.length > NAME_TRUNCATION_LENGTH ? (
truncatedTooltip('activity_modal.long_task_tooltip', activity.task) truncatedTooltip("activity_modal.long_task_tooltip", activity.task)
):( ) : (
<span>{activity.task}</span> <span>{activity.task}</span>
)}&nbsp;] )}&nbsp;]
</span> </span>
); );
} }
const ActivityElement = ({ activity }: InputActivity ): Node => (
const ActivityElement = ({ activity }) =>
<StyledLi> <StyledLi>
<TimeSpan> <TimeSpan>
<Moment format="HH.mm"> <FormattedDate
{activity.created_at} value={new Date(activity.createdAt)}
</Moment> hour="2-digit"
minute="2-digit"
timeZone={activity.timezone}
hour12={false}
/>
</TimeSpan> </TimeSpan>
<TextSpan> <TextSpan>
<span dangerouslySetInnerHTML={{ __html: activity.message }} /> <span dangerouslySetInnerHTML={{ __html: activity.message }} />
{activity.task && taskPath(activity)} {activity.task && taskPath(activity)}
</TextSpan> </TextSpan>
</StyledLi>; </StyledLi>
);
export default ActivityElement; export default ActivityElement;

View file

@ -1,7 +1,7 @@
// @flow // @flow
import React, { Component } from "react"; import React, { Component } from "react";
import type { Element } from "react"; import type { Element, Node } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { Button, Modal } from "react-bootstrap"; import { Button, Modal } from "react-bootstrap";
import _ from "lodash"; import _ from "lodash";
@ -53,7 +53,8 @@ type Props = {
type State = { type State = {
activities: Array<Activity>, activities: Array<Activity>,
more: boolean more: boolean,
currentPage: number
}; };
class GlobalActivitiesModal extends Component<Props, State> { class GlobalActivitiesModal extends Component<Props, State> {
@ -61,7 +62,7 @@ class GlobalActivitiesModal extends Component<Props, State> {
key: number, key: number,
activity: Activity, activity: Activity,
date: Date date: Date
) { ): Node {
return [ return [
<ActivityDateElement key={date} date={date} />, <ActivityDateElement key={date} date={date} />,
<ActivityElement key={key} activity={activity} /> <ActivityElement key={key} activity={activity} />
@ -70,7 +71,7 @@ class GlobalActivitiesModal extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { activities: [], more: false }; this.state = { activities: [], more: false, currentPage: 1 };
(this: any).displayActivities = this.displayActivities.bind(this); (this: any).displayActivities = this.displayActivities.bind(this);
(this: any).addMoreActivities = this.addMoreActivities.bind(this); (this: any).addMoreActivities = this.addMoreActivities.bind(this);
(this: any).onCloseModalActions = this.onCloseModalActions.bind(this); (this: any).onCloseModalActions = this.onCloseModalActions.bind(this);
@ -79,7 +80,7 @@ class GlobalActivitiesModal extends Component<Props, State> {
} }
onCloseModalActions(): void { onCloseModalActions(): void {
this.setState({ activities: [], more: false }); this.setState({ activities: [], more: false, currentPage: 1 });
this.props.onCloseModal(); this.props.onCloseModal();
} }
@ -87,37 +88,43 @@ class GlobalActivitiesModal extends Component<Props, State> {
getActivities().then(response => { getActivities().then(response => {
this.setState({ this.setState({
activities: response.activities, activities: response.activities,
more: response.more more: response.more,
currentPage: response.currentPage
}); });
}); });
} }
mapActivities(): Array<*> { mapActivities(): Array<*> {
return this.state.activities.map((activity, i, arr) => { return this.state.activities.map(
// @todo replace key={i} with key={activity.id} !!!!!!!!!!!!!! (activity: Activity, i: number, arr: Array<*>) => {
// when the backend bug will be fixed const newDate = new Date(activity.createdAt);
const newDate = new Date(activity.created_at); // returns a label with "today" if the date of the activity is today
// returns a label with "today" if the date of the activity is today if (i === 0 && newDate.toDateString() === new Date().toDateString()) {
if (i === 0) { return GlobalActivitiesModal.renderActivityDateElement(
return GlobalActivitiesModal.renderActivityDateElement( activity.id,
i, activity,
activity, newDate
newDate );
); }
// else checks if the previous activity is newer than current
// and displays a label with the date
const prevDate =
i !== 0 ? new Date(arr[i - 1].createdAt) : new Date(1901, 1, 1);
// filter only date from createdAt without minutes and seconds
// used to compare dates
const parsePrevDate = new Date(prevDate.toDateString());
const parseNewDate = new Date(newDate.toDateString());
if (parsePrevDate.getTime() > parseNewDate.getTime()) {
return GlobalActivitiesModal.renderActivityDateElement(
activity.id,
activity,
newDate
);
}
// returns the default activity element
return <ActivityElement key={activity.id} activity={activity} />;
} }
// else checks if the previous activity is newer than current );
// and displays a label with the date
const prevDate = new Date(arr[i - 1].created_at);
if (prevDate.getDate() > newDate.getDate()) {
return GlobalActivitiesModal.renderActivityDateElement(
i,
activity,
newDate
);
}
// returns the default activity element
return <ActivityElement key={i} activity={activity} />;
});
} }
displayActivities() { displayActivities() {
@ -136,15 +143,21 @@ class GlobalActivitiesModal extends Component<Props, State> {
} }
addMoreActivities(): void { addMoreActivities(): void {
const lastId = _.last(this.state.activities).id;
getActivities( getActivities(
lastId this.state.currentPage + 1
).then((response: { activities: Array<Activity>, more: boolean }) => { ).then(
this.setState({ (response: {
activities: [...this.state.activities, ...response.activities], activities: Array<Activity>,
more: response.more more: boolean,
}); currentPage: number
}); }) => {
this.setState({
activities: [...this.state.activities, ...response.activities],
more: response.more,
currentPage: response.currentPage
});
}
);
} }
addMoreButton(): Element<*> { addMoreButton(): Element<*> {

View file

@ -14,9 +14,9 @@ import { getSciNoteInfo } from "../../../services/api/configurations_api";
import AboutScinoteModal from "./AboutScinoteModal"; import AboutScinoteModal from "./AboutScinoteModal";
type State = { type State = {
showModal: boolean,
scinoteVersion: string, scinoteVersion: string,
addons: Array<string> addons: Array<string>,
showModal: boolean
}; };
class InfoDropdown extends Component<*, State> { class InfoDropdown extends Component<*, State> {

View file

@ -3,9 +3,9 @@ import axiosInstance from "./config";
import { ACTIVITIES_PATH } from "./endpoints"; import { ACTIVITIES_PATH } from "./endpoints";
export function getActivities( export function getActivities(
lastId: number = 0 page: number = 1
): Promise<*> { ): Promise<*> {
const path = `${ACTIVITIES_PATH}?from=${lastId}`; const path = `${ACTIVITIES_PATH}?page=${page}`;
return axiosInstance.get(path).then(({ data }) => data.global_activities); return axiosInstance.get(path).then(({ data }) => data.global_activities);
} }

View file

@ -324,24 +324,24 @@ class User < ApplicationRecord
# Finds all activities of user that is assigned to project. If user # Finds all activities of user that is assigned to project. If user
# is not an owner of the project, user must be also assigned to # is not an owner of the project, user must be also assigned to
# module. # module.
def last_activities(last_activity_id = nil, def last_activities
per_page = Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT)
last_activity_id = Constants::INFINITY if last_activity_id < 1
Activity Activity
.joins(project: :user_projects) .joins(project: :user_projects)
.joins("LEFT OUTER JOIN my_modules ON activities.my_module_id = my_modules.id") .joins(
.joins("LEFT OUTER JOIN user_my_modules ON my_modules.id = user_my_modules.my_module_id") 'LEFT OUTER JOIN my_modules ON activities.my_module_id = my_modules.id'
.where('activities.id < ?', last_activity_id) )
.joins(
'LEFT OUTER JOIN user_my_modules ON my_modules.id = ' \
'user_my_modules.my_module_id'
)
.where(user_projects: { user_id: self }) .where(user_projects: { user_id: self })
.where( .where(
'activities.my_module_id IS NULL OR ' + 'activities.my_module_id IS NULL OR ' \
'user_projects.role = 0 OR ' + 'user_projects.role = 0 OR ' \
'user_my_modules.user_id = ?', 'user_my_modules.user_id = ?',
id id
) )
.order(created_at: :desc) .order(created_at: :desc)
.limit(per_page)
.uniq
end end
def self.find_by_valid_wopi_token(token) def self.find_by_valid_wopi_token(token)

View file

@ -1,12 +1,16 @@
<ul class="no-style double-line content-activities"> <ul class="no-style double-line content-activities">
<% if @activities.length == 0 then %> <% if activities.empty? %>
<li><em><%= t'activities.index.no_activities' %></em></li> <li><em><%= t'activities.index.no_activities' %></em></li>
<% else %> <% else %>
<%= render 'activities/list.html.erb', activities: @activities, hide_today: hide_today, day: @day %> <%= render 'activities/list.html.erb',
activities: activities,
previous_activity_created_at: previous_activity_created_at %>
<% end %> <% end %>
<% if @last_activity_id < 1 and @overflown %> <% if more_activities_url.present? && page == 1 %>
<li class="text-center"> <li class="text-center">
<a class="btn btn-default btn-more-activities" href="<%= more_activities_url %>" data-remote="true"> <a class="btn btn-default btn-more-activities"
href="<%= more_activities_url %>"
data-remote="true">
<%= t'activities.index.more_activities' %></a> <%= t'activities.index.more_activities' %></a>
</li> </li>
<% end %> <% end %>

View file

@ -1,18 +1,13 @@
<% <% if activities.first_page? && activities.first.created_at.to_date == Date.today %>
current_day = days_since_1970(DateTime.current)
%>
<% if !hide_today && activities.count > 0 && days_since_1970(activities[0].created_at) == current_day %>
<li class="text-center activity-date-item"> <li class="text-center activity-date-item">
<span class="label label-primary"> <span class="label label-primary">
<%=t "activities.index.today" %> <%=t "activities.index.today" %>
</span> </span>
</li> </li>
<% end %> <% end %>
<% activities.each do |activity| %> <% activities.each_with_index do |activity, index| %>
<% activity_day = days_since_1970(activity.created_at) %> <% prevDate = calculate_previous_date(activities, index, previous_activity_created_at) %>
<% if activity.created_at.to_date < prevDate %>
<% if activity_day < current_day and activity_day < day %>
<% day = days_since_1970(activity.created_at) %>
<li class="text-center activity-date-item"> <li class="text-center activity-date-item">
<span class="label label-primary"> <span class="label label-primary">
<%= activity.created_at.strftime('%d.%m.%Y') %> <%= activity.created_at.strftime('%d.%m.%Y') %>

View file

@ -1,12 +1,14 @@
json.global_activities do json.global_activities do
json.more more json.more more
json.currentPage page
json.activities activities do |activity| json.activities activities do |activity|
json.id activity.id json.id activity.id
json.message activity.message json.message activity.message
json.createdAt activity.created_at
json.timezone timezone
if activity.my_module if activity.my_module
json.project activity.my_module.experiment.project.name json.project activity.my_module.experiment.project.name
json.task activity.my_module.name json.task activity.my_module.name
end end
json.created_at activity.created_at
end end
end end

7
flow-typed/types.js vendored
View file

@ -33,9 +33,10 @@ export type ValidationErrors = string | Array<string> | Array<ValidationError>;
export type Activity = { export type Activity = {
id: number, id: number,
message: string, message: string,
created_at: string, createdAt: string,
project?: string, timezone: string,
task?: string project: string,
task: string
}; };
export type Notification = { export type Notification = {

View file

@ -0,0 +1,20 @@
require 'rails_helper'
describe ClientApi::ActivitiesController, type: :controller do
login_user
render_views
before do
project = create :project, created_by: User.first
UserProject.create(user: User.first, project: project, role: 2)
create :activity, user: User.first, project: project
end
describe '#index' do
it 'returns a valid object' do
get :index, format: :json
expect(response.status).to eq(200)
expect(response.body).to match_response_schema('activities')
end
end
end

View file

@ -0,0 +1,7 @@
FactoryGirl.define do
factory :activity do
type_of :create_project
message Faker::Lorem.sentence(10)
project { Project.first || create(:project) }
end
end

View file

@ -0,0 +1,5 @@
FactoryGirl.define do
factory :user_project do
role 'owner'
end
end

View file

@ -179,4 +179,19 @@ describe User, type: :model do
it { is_expected.to respond_to(:recent_email_notification) } it { is_expected.to respond_to(:recent_email_notification) }
it { is_expected.to respond_to(:system_message_email_notification) } it { is_expected.to respond_to(:system_message_email_notification) }
end end
describe '#last_activities' do
let!(:user) { create :user }
let!(:project) { create :project }
let!(:user_projects) { create :user_project, project: project, user: user }
let!(:activity_one) { create :activity, user: user, project: project }
let!(:activity_two) { create :activity, user: user, project: project }
it 'is expected to return an array of user\'s activities' do
activities = user.last_activities
expect(activities.count).to eq 2
expect(activities).to include activity_one
expect(activities).to include activity_two
end
end
end end

View file

@ -0,0 +1,26 @@
{
"type": "object",
"required": ["global_activities"],
"properties": {
"global_activities": {
"type": "object",
"required": ["activities", "more", "currentPage"],
"properties": {
"more": { "type": "boolean" },
"currentPage": { "type": "integer" },
"activities": {
"type": "array",
"items":{
"required": ["id", "message", "createdAt", "timezone"],
"properties": {
"id": { "type": "integer" },
"message": { "type": "string" },
"createdAt": { "type": "string" },
"timezone": { "type": "string" }
}
}
}
}
}
}
}