diff --git a/app/assets/javascripts/navigation.js b/app/assets/javascripts/navigation.js index 4b19db7cc..4e731df63 100644 --- a/app/assets/javascripts/navigation.js +++ b/app/assets/javascripts/navigation.js @@ -109,14 +109,14 @@ // Activity feed modal in main navigation menu var activityModal = $('#activity-modal'); var activityModalBody = activityModal.find('.modal-body'); - var initMoreBtn = function() { activityModalBody.find('.btn-more-activities') .on('ajax:success', function(e, data) { $(data.html).insertBefore($(this).parents('li')); - $(this).attr('href', data.next_url); - if (data.activities_number < data.per_page) { - $(this).remove(); + if(data.more_url) { + $(this).attr('href', data.more_url); + } else { + $(this).remove(); } }); }; diff --git a/app/controllers/activities_controller.rb b/app/controllers/activities_controller.rb index 29629df31..1563bf73f 100644 --- a/app/controllers/activities_controller.rb +++ b/app/controllers/activities_controller.rb @@ -1,47 +1,43 @@ class ActivitiesController < ApplicationController include ActivityHelper - before_action :load_vars - 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| - format.json { - render :json => { - per_page: @per_page, - activities_number: @activities.length, - next_url: more_url, - html: render_to_string({ - partial: 'index.html.erb', - locals: { - more_activities_url: more_url, - hide_today: @hide_today, - day: @day - } - }) + format.json do + render json: { + more_url: local_vars.fetch(:more_activities_url), + html: render_to_string( + partial: 'index.html.erb', locals: local_vars + ) } - } + end end end - def load_vars - @last_activity_id = params[:from].to_i || 0 - @last_activity = Activity.find_by_id(@last_activity_id) + private + + 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 diff --git a/app/controllers/client_api/activities_controller.rb b/app/controllers/client_api/activities_controller.rb index ae662cd02..a92caa3a6 100644 --- a/app/controllers/client_api/activities_controller.rb +++ b/app/controllers/client_api/activities_controller.rb @@ -14,11 +14,17 @@ module ClientApi private def activities_vars - last_activity_id = params[:from].to_i || 0 - per_page = Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT - activities = current_user.last_activities(last_activity_id, per_page + 1) - more = activities.length > per_page - { activities: activities, more: more } + page = (params[:page] || 1).to_i + activities = current_user + .last_activities + .page(page) + .per(Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT) + { + activities: activities, + page: page, + more: !activities.last_page?, + timezone: current_user.time_zone + } end end end diff --git a/app/helpers/activity_helper.rb b/app/helpers/activity_helper.rb index feb13115a..95bea380b 100644 --- a/app/helpers/activity_helper.rb +++ b/app/helpers/activity_helper.rb @@ -56,4 +56,15 @@ module ActivityHelper def days_since_1970(dt) dt.to_i / 86400 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 diff --git a/app/javascript/src/components/Navigation/components/ActivityElement.jsx b/app/javascript/src/components/Navigation/components/ActivityElement.jsx index 08b7ab10b..df3f0d08c 100644 --- a/app/javascript/src/components/Navigation/components/ActivityElement.jsx +++ b/app/javascript/src/components/Navigation/components/ActivityElement.jsx @@ -1,10 +1,10 @@ // @flow 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 styled from "styled-components"; -import { FormattedMessage } from "react-intl"; import { WHITE_COLOR, @@ -14,8 +14,12 @@ import { import { NAME_TRUNCATION_LENGTH } from "../../../config/constants/numeric"; +type InputActivity = { + activity: Activity +} + const StyledLi = styled.li` - border-radius: .25em; + border-radius: 0.25em; margin-bottom: 1em; background-color: ${WHITE_COLOR}; border: 1px solid ${COLOR_CONCRETE}; @@ -24,8 +28,8 @@ const TimeSpan = styled.span` min-width: 150px; display: table-cell; vertical-align: middle; - border-top-left-radius: .25em; - border-bottom-left-radius: .25em; + border-top-left-radius: 0.25em; + border-bottom-left-radius: 0.25em; border: 3px solid ${BORDER_GRAY_COLOR}; background-color: ${BORDER_GRAY_COLOR}; padding-left: 10px; @@ -37,52 +41,57 @@ const TextSpan = styled.span` display: table-cell; padding: 3px 10px; text-align: justify; -` -function truncatedTooltip(id, text) { +`; + +function truncatedTooltip(id: string, text: any): Node { return ( - - {text} - - )} placement="bottom"> - - {text.substring(0, NAME_TRUNCATION_LENGTH)}... - + {text}} + placement="bottom" + > + {text.substring(0, NAME_TRUNCATION_LENGTH)}... ); } -function taskPath(activity) { +function taskPath(activity: Activity): Node { return ( -   - [ :  + +   [ :  {activity.project.length > NAME_TRUNCATION_LENGTH ? ( - truncatedTooltip('activity_modal.long_project_tooltip', activity.project) - ):( + truncatedTooltip( + "activity_modal.long_project_tooltip", + activity.project + ) + ) : ( {activity.project} )},  :  {activity.task.length > NAME_TRUNCATION_LENGTH ? ( - truncatedTooltip('activity_modal.long_task_tooltip', activity.task) - ):( + truncatedTooltip("activity_modal.long_task_tooltip", activity.task) + ) : ( {activity.task} )} ] ); } - -const ActivityElement = ({ activity }) => +const ActivityElement = ({ activity }: InputActivity ): Node => ( - - {activity.created_at} - + {activity.task && taskPath(activity)} - ; + +); export default ActivityElement; diff --git a/app/javascript/src/components/Navigation/components/GlobalActivitiesModal.jsx b/app/javascript/src/components/Navigation/components/GlobalActivitiesModal.jsx index fe70d3224..e5bd8cd4a 100644 --- a/app/javascript/src/components/Navigation/components/GlobalActivitiesModal.jsx +++ b/app/javascript/src/components/Navigation/components/GlobalActivitiesModal.jsx @@ -1,7 +1,7 @@ // @flow import React, { Component } from "react"; -import type { Element } from "react"; +import type { Element, Node } from "react"; import { FormattedMessage } from "react-intl"; import { Button, Modal } from "react-bootstrap"; import _ from "lodash"; @@ -53,7 +53,8 @@ type Props = { type State = { activities: Array, - more: boolean + more: boolean, + currentPage: number }; class GlobalActivitiesModal extends Component { @@ -61,7 +62,7 @@ class GlobalActivitiesModal extends Component { key: number, activity: Activity, date: Date - ) { + ): Node { return [ , @@ -70,7 +71,7 @@ class GlobalActivitiesModal extends Component { constructor(props: Props) { super(props); - this.state = { activities: [], more: false }; + this.state = { activities: [], more: false, currentPage: 1 }; (this: any).displayActivities = this.displayActivities.bind(this); (this: any).addMoreActivities = this.addMoreActivities.bind(this); (this: any).onCloseModalActions = this.onCloseModalActions.bind(this); @@ -79,7 +80,7 @@ class GlobalActivitiesModal extends Component { } onCloseModalActions(): void { - this.setState({ activities: [], more: false }); + this.setState({ activities: [], more: false, currentPage: 1 }); this.props.onCloseModal(); } @@ -87,37 +88,43 @@ class GlobalActivitiesModal extends Component { getActivities().then(response => { this.setState({ activities: response.activities, - more: response.more + more: response.more, + currentPage: response.currentPage }); }); } mapActivities(): Array<*> { - return this.state.activities.map((activity, i, arr) => { - // @todo replace key={i} with key={activity.id} !!!!!!!!!!!!!! - // when the backend bug will be fixed - const newDate = new Date(activity.created_at); - // returns a label with "today" if the date of the activity is today - if (i === 0) { - return GlobalActivitiesModal.renderActivityDateElement( - i, - activity, - newDate - ); + return this.state.activities.map( + (activity: Activity, i: number, arr: Array<*>) => { + const newDate = new Date(activity.createdAt); + // returns a label with "today" if the date of the activity is today + if (i === 0 && newDate.toDateString() === new Date().toDateString()) { + return GlobalActivitiesModal.renderActivityDateElement( + activity.id, + activity, + 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 ; } - // 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 ; - }); + ); } displayActivities() { @@ -136,15 +143,21 @@ class GlobalActivitiesModal extends Component { } addMoreActivities(): void { - const lastId = _.last(this.state.activities).id; getActivities( - lastId - ).then((response: { activities: Array, more: boolean }) => { - this.setState({ - activities: [...this.state.activities, ...response.activities], - more: response.more - }); - }); + this.state.currentPage + 1 + ).then( + (response: { + activities: Array, + more: boolean, + currentPage: number + }) => { + this.setState({ + activities: [...this.state.activities, ...response.activities], + more: response.more, + currentPage: response.currentPage + }); + } + ); } addMoreButton(): Element<*> { diff --git a/app/javascript/src/components/Navigation/components/InfoDropdown.jsx b/app/javascript/src/components/Navigation/components/InfoDropdown.jsx index 1468827e6..86fbf0247 100644 --- a/app/javascript/src/components/Navigation/components/InfoDropdown.jsx +++ b/app/javascript/src/components/Navigation/components/InfoDropdown.jsx @@ -14,9 +14,9 @@ import { getSciNoteInfo } from "../../../services/api/configurations_api"; import AboutScinoteModal from "./AboutScinoteModal"; type State = { - showModal: boolean, scinoteVersion: string, - addons: Array + addons: Array, + showModal: boolean }; class InfoDropdown extends Component<*, State> { diff --git a/app/javascript/src/services/api/activities_api.js b/app/javascript/src/services/api/activities_api.js index 743ae55b8..0d7a5c599 100644 --- a/app/javascript/src/services/api/activities_api.js +++ b/app/javascript/src/services/api/activities_api.js @@ -3,9 +3,9 @@ import axiosInstance from "./config"; import { ACTIVITIES_PATH } from "./endpoints"; export function getActivities( - lastId: number = 0 + page: number = 1 ): Promise<*> { - const path = `${ACTIVITIES_PATH}?from=${lastId}`; + const path = `${ACTIVITIES_PATH}?page=${page}`; return axiosInstance.get(path).then(({ data }) => data.global_activities); } diff --git a/app/models/user.rb b/app/models/user.rb index e99cd580a..4e3aa49b3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -324,24 +324,24 @@ class User < ApplicationRecord # 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 # module. - def last_activities(last_activity_id = nil, - per_page = Constants::ACTIVITY_AND_NOTIF_SEARCH_LIMIT) - last_activity_id = Constants::INFINITY if last_activity_id < 1 + def last_activities Activity .joins(project: :user_projects) - .joins("LEFT OUTER JOIN my_modules ON activities.my_module_id = my_modules.id") - .joins("LEFT OUTER JOIN user_my_modules ON my_modules.id = user_my_modules.my_module_id") - .where('activities.id < ?', last_activity_id) + .joins( + 'LEFT OUTER JOIN my_modules ON activities.my_module_id = my_modules.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( - 'activities.my_module_id IS NULL OR ' + - 'user_projects.role = 0 OR ' + + 'activities.my_module_id IS NULL OR ' \ + 'user_projects.role = 0 OR ' \ 'user_my_modules.user_id = ?', id ) .order(created_at: :desc) - .limit(per_page) - .uniq end def self.find_by_valid_wopi_token(token) diff --git a/app/views/activities/_index.html.erb b/app/views/activities/_index.html.erb index 2c7eeac6b..fd119ff3a 100644 --- a/app/views/activities/_index.html.erb +++ b/app/views/activities/_index.html.erb @@ -1,12 +1,16 @@
    - <% if @activities.length == 0 then %> + <% if activities.empty? %>
  • <%= t'activities.index.no_activities' %>
  • <% 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 %> - <% if @last_activity_id < 1 and @overflown %> + <% if more_activities_url.present? && page == 1 %>
  • - + <%= t'activities.index.more_activities' %>
  • <% end %> diff --git a/app/views/activities/_list.html.erb b/app/views/activities/_list.html.erb index 5f633b587..bb8c03a9b 100644 --- a/app/views/activities/_list.html.erb +++ b/app/views/activities/_list.html.erb @@ -1,18 +1,13 @@ -<% - current_day = days_since_1970(DateTime.current) -%> -<% if !hide_today && activities.count > 0 && days_since_1970(activities[0].created_at) == current_day %> +<% if activities.first_page? && activities.first.created_at.to_date == Date.today %>
  • <%=t "activities.index.today" %>
  • <% end %> -<% activities.each do |activity| %> - <% activity_day = days_since_1970(activity.created_at) %> - - <% if activity_day < current_day and activity_day < day %> - <% day = days_since_1970(activity.created_at) %> +<% activities.each_with_index do |activity, index| %> + <% prevDate = calculate_previous_date(activities, index, previous_activity_created_at) %> + <% if activity.created_at.to_date < prevDate %>
  • <%= activity.created_at.strftime('%d.%m.%Y') %> diff --git a/app/views/client_api/activities/index.json.jbuilder b/app/views/client_api/activities/index.json.jbuilder index a932cd678..7ba2131d3 100644 --- a/app/views/client_api/activities/index.json.jbuilder +++ b/app/views/client_api/activities/index.json.jbuilder @@ -1,12 +1,14 @@ json.global_activities do json.more more + json.currentPage page json.activities activities do |activity| json.id activity.id json.message activity.message + json.createdAt activity.created_at + json.timezone timezone if activity.my_module json.project activity.my_module.experiment.project.name json.task activity.my_module.name end - json.created_at activity.created_at end end diff --git a/flow-typed/types.js b/flow-typed/types.js index 3aed6fe42..b15074959 100644 --- a/flow-typed/types.js +++ b/flow-typed/types.js @@ -33,9 +33,10 @@ export type ValidationErrors = string | Array | Array; export type Activity = { id: number, message: string, - created_at: string, - project?: string, - task?: string + createdAt: string, + timezone: string, + project: string, + task: string }; export type Notification = { diff --git a/spec/controllers/client_api/activities_controller_spec.rb b/spec/controllers/client_api/activities_controller_spec.rb new file mode 100644 index 000000000..bd0a8b093 --- /dev/null +++ b/spec/controllers/client_api/activities_controller_spec.rb @@ -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 diff --git a/spec/factories/activity.rb b/spec/factories/activity.rb new file mode 100644 index 000000000..8ae4343d6 --- /dev/null +++ b/spec/factories/activity.rb @@ -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 diff --git a/spec/factories/user_project.rb b/spec/factories/user_project.rb new file mode 100644 index 000000000..24a2c6a34 --- /dev/null +++ b/spec/factories/user_project.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :user_project do + role 'owner' + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0d254c439..dd25cb334 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -179,4 +179,19 @@ describe User, type: :model do it { is_expected.to respond_to(:recent_email_notification) } it { is_expected.to respond_to(:system_message_email_notification) } 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 diff --git a/spec/support/api/schemas/activities.json b/spec/support/api/schemas/activities.json new file mode 100644 index 000000000..c0570f640 --- /dev/null +++ b/spec/support/api/schemas/activities.json @@ -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" } + } + } + } + } + } + } +}