Skip to content
Snippets Groups Projects
Commit 1393b59a authored by Allen Gilliland's avatar Allen Gilliland
Browse files

Merge pull request #956 from metabase/activity_feed

New homepage implementation with Activity Feed + Recently Viewed
parents cc2a32ce 8e350cba
No related branches found
No related tags found
No related merge requests found
Showing
with 646 additions and 69 deletions
......@@ -8,6 +8,7 @@
:aliases {"test" ["with-profile" "+expectations" "expectations"]
"generate-sample-dataset" ["with-profile" "+generate-sample-dataset" "run"]}
:dependencies [[org.clojure/clojure "1.7.0"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]
[org.clojure/core.logic "0.8.10"]
[org.clojure/core.match "0.3.0-alpha4"] ; optimized pattern matching library for Clojure
[org.clojure/core.memoize "0.5.7"] ; needed by core.match; has useful FIFO, LRU, etc. caching mechanisms
......
'use strict';
var ActivityServices = angular.module('metabase.activity.services', ['ngResource', 'ngCookies']);
ActivityServices.factory('Activity', ['$resource', '$cookies', function($resource, $cookies) {
return $resource('/api/activity', {}, {
list: {
method: 'GET',
isArray: true
},
recent_views: {
url: '/api/activity/recent_views',
method: 'GET',
isArray: true
}
});
}]);
......@@ -5,6 +5,7 @@ var Metabase = angular.module('metabase', [
'ngRoute',
'ngCookies',
'ui.bootstrap', // bootstrap LIKE widgets via angular directives
'metabase.activity.services',
'metabase.auth',
'metabase.filters',
'metabase.directives',
......
......@@ -8,7 +8,7 @@ var CardServices = angular.module('metabase.card.services', ['ngResource', 'ngCo
CardServices.factory('Card', ['$resource', '$cookies', function($resource, $cookies) {
return $resource('/api/card/:cardId', {}, {
list: {
url: '/api/card/?org=:orgId&f=:filterMode',
url: '/api/card/?f=:filterMode',
method: 'GET',
isArray: true
},
......
'use strict';
import React, { Component } from 'react';
import cx from "classnames";
var IconBorder = React.createClass({
displayName: 'IconBorder',
getDefaultProps: function () {
return {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'currentcolor',
rounded: true
}
},
computeSize: function () {
var width = parseInt(this.props.children.props.width, 10);
export default class IconBorder extends Component {
constructor() {
super();
this.state = {};
}
componentDidMount() {
this.setState({
childWidth: React.findDOMNode(this.refs.child).offsetWidth
});
}
computeSize () {
let width = parseInt(this.state.childWidth, 10);
return width * 2;
},
render: function () {
var classes = cx({
}
render() {
const classes = cx({
'flex': true,
'layout-centered': true
});
var styles = {
const styles = {
width: this.computeSize(),
height: this.computeSize(),
borderWidth: this.props.borderWidth,
borderStyle: this.props.borderStyle,
borderColor: this.props.borderColor
borderColor: this.props.borderColor,
lineHeight: '1px', /* HACK this is dumb but it centers the icon in the border */
}
if (this.props.borderRadius) {
......@@ -37,11 +40,17 @@ var IconBorder = React.createClass({
}
return (
<span className={classes + ' ' + this.props.className} style={styles}>
{this.props.children}
<span className={classes + ' ' + this.props.className} style={Object.assign(styles, this.props.style)}>
<span ref="child">{this.props.children}</span>
</span>
);
}
});
}
export default IconBorder;
IconBorder.defaultProps = {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'currentcolor',
rounded: true,
style: {},
}
......@@ -4,6 +4,7 @@ import React, { Component, PropTypes } from 'react';
import OnClickOut from 'react-onclickout';
import cx from 'classnames';
import UserAvatar from './UserAvatar.react';
import Icon from './Icon.react';
export default class ProfileLink extends Component {
......@@ -22,23 +23,8 @@ export default class ProfileLink extends Component {
this.setState({ dropdownOpen: false });
}
displayInitials() {
let initials = '??';
const { user } = this.props;
if (user.first_name !== 'undefined') {
initials = user.first_name.substring(0, 1);
}
if (user.last_name !== 'undefined') {
initials = initials + user.last_name.substring(0, 1);
}
return initials;
}
render() {
const { user, context } = this.props;
let dropDownClasses = cx({
'NavDropdown': true,
'inline-block': true,
......@@ -46,15 +32,14 @@ export default class ProfileLink extends Component {
'open': this.state.dropdownOpen,
})
return (
<OnClickOut onClickOut={this.closeDropdown}>
<div className={dropDownClasses}>
<a className="NavDropdown-button NavItem flex align-center p2" onClick={this.toggleDropdown}>
<div className="NavDropdown-button-layer">
<div className="flex align-center">
<span className="UserNick">
<span className="UserInitials NavItem-text">{this.displayInitials()}</span>
</span>
<UserAvatar user={user} style={{backgroundColor: 'transparent'}}/>
<Icon name="chevrondown" className="Dropdown-chevron ml1" width="8px" height="8px" />
</div>
</div>
......
'use strict';
import React, { Component } from 'react';
import cx from 'classnames';
export default class UserAvatar extends Component {
constructor(props) {
super(props);
this.styles = {
fontSize: '0.85rem',
borderWidth: '1px',
borderStyle: 'solid',
borderRadius: '99px',
width: '2rem',
height: '2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
}
userInitials() {
const { first_name, last_name } = this.props.user;
let initials = '??';
if (first_name !== 'undefined') {
initials = first_name.substring(0, 1);
}
if (last_name !== 'undefined') {
initials = initials + last_name.substring(0, 1);
}
return initials;
}
render() {
const { background } = this.props;
const classes = {
'flex': true,
'align-center': true,
}
classes[background] = true;
return (
<div className={cx(classes)} style={Object.assign(this.styles, this.props.style)}>
{this.userInitials()}
</div>
)
}
}
UserAvatar.defaultProps = {
background: 'bg-brand',
}
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="58px" height="57px" viewBox="0 0 58 57" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-393.000000, -53.000000)" fill="#4F9CE0">
<g transform="translate(-6.000000, -9.000000)">
<g transform="translate(13.000000, -3.000000)">
<rect transform="translate(415.000000, 93.715729) rotate(-45.000000) translate(-415.000000, -93.715729) " x="395" y="73.7157288" width="40" height="40"></rect>
</g>
</g>
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g fill="#ffffff">
<path d="M24,44 C35.045695,44 44,35.045695 44,24 C44,12.954305 35.045695,4 24,4 C12.954305,4 4,12.954305 4,24 C4,35.045695 12.954305,44 24,44 Z M18.173913,22.0544037 C19.1344083,22.0544037 19.9130435,20.3819413 19.9130435,18.3188544 C19.9130435,16.2557674 19.1344083,14.583305 18.173913,14.583305 C17.2134178,14.583305 16.4347826,16.2557674 16.4347826,18.3188544 C16.4347826,20.3819413 17.2134178,22.0544037 18.173913,22.0544037 Z M28.9565217,22.0544037 C29.917017,22.0544037 30.6956522,20.3819413 30.6956522,18.3188544 C30.6956522,16.2557674 29.917017,14.583305 28.9565217,14.583305 C27.9960265,14.583305 27.2173913,16.2557674 27.2173913,18.3188544 C27.2173913,20.3819413 27.9960265,22.0544037 28.9565217,22.0544037 Z M24,35.5616363 C28.4700217,35.5616363 32.1182868,31.5056662 32.1182868,27.0356445 C26.4909049,27.0356446 23.3555312,27.0356445 15.9608154,27.0356445 C15.9608154,31.5056662 19.5299783,35.5616363 24,35.5616363 Z"></path>
</g>
</g>
</svg>
......@@ -39,22 +39,6 @@ MetabaseControllers.controller('Metabase', ['$scope', '$location', 'MetabaseCore
}]);
MetabaseControllers.controller('Homepage', ['$scope', '$location', 'ipCookie', 'AppState',
function($scope, $location, ipCookie, AppState) {
// At this point in time we don't actually have any kind of content to show for a homepage, so we just use this
// as a simple routing controller which sends users somewhere relevant
if (AppState.model.currentUser) {
$location.path('/dash/');
} else {
// User is not logged-in, so always send them to login page
$location.path('/auth/login');
}
}
]);
MetabaseControllers.controller('Unauthorized', ['$scope', '$location', function($scope, $location) {
}]);
......
......@@ -50,35 +50,10 @@
transition: background .2s linear;
}
.UserNick {
border-width: 1px;
border-style: solid;
border-radius: 99px;
width: 2rem;
height: 2rem; /* set an explicit height since we want it to be square */
display: flex;
align-items: center;
justify-content: center;
}
.AdminNav .UserNick {
color: #fff;
border-color: #fff;
background-color: #fff;
background-color: rgba(255, 255, 255, 0.3);
}
.AdminNav .Dropdown-chevron {
color: #fff;
}
.UserInitials,
.UserInitials:hover {
font-size: 0.85rem;
color: currentColor;
cursor: pointer;
}
.Actions {
background-color: rgba(243,243,243,0.46);
border: 1px solid #E0E0E0;
......
......@@ -96,6 +96,8 @@
}
.bg-gold { background-color: var(--gold-color); }
.bg-purple { background-color: var(--purple-color); }
.bg-green { background-color: var(--green-color); }
/* alt */
.bg-alt { background-color: var(--alt-color); }
......
.hide { display: none !important; }
.show { display: block; }
.show { display: inheirt; }
.sm-show,
.md-show,
......@@ -12,7 +12,7 @@
.sm-hide { display: none !important; }
}
@media screen and (--breakpoint-min-sm) {
.sm-show { display: block !important; }
.sm-show { display: inherit !important; }
}
/* medium */
......@@ -21,7 +21,7 @@
.md-hide { display: none !important; }
}
@media screen and (--breakpoint-min-md) {
.md-show { display: block !important; }
.md-show { display: inherit !important; }
}
/* large */
......@@ -29,7 +29,7 @@
.lg-hide { display: none !important; }
}
@media screen and (--breakpoint-min-lg) {
.lg-show { display: block !important; }
.lg-show { display: inherit !important; }
}
/* xl */
......@@ -37,5 +37,5 @@
.xl-hide { display: none !important; }
}
@media screen and (--breakpoint-min-xl) {
.xl-show { display: block !important; }
.xl-show { display: inherit !important; }
}
......@@ -7,8 +7,6 @@
.wrapper {
width: 100%;
margin: 0 auto;
padding-left: 1.5em;
padding-right: 1.5em;
}
@media screen and (--breakpoint-min-sm) {
......
......@@ -2,6 +2,15 @@
z-index: 2;
}
.CheckBg {
background-image: url('/app/components/icons/assets/header_rect.svg');
background-repeat: repeat;
}
.CheckBg-offset {
background-position-y: -15px;
}
.Main {
z-index: 1;
position: relative;
......@@ -31,13 +40,13 @@
background-color: rgba(255, 255, 255, 0.2);
border-radius: 4px 4px 0 0;
text-decoration: none;
padding: 0.55rem 1rem 0.65rem;
padding: 0.55rem 1rem;
transition: background .15s linear;
}
@media screen and (--breakpoint-min-sm) {
.HomeTab {
padding: 0.65rem 1.35rem 0.75rem;
padding: 0.65rem 1.35rem;
}
}
......@@ -197,3 +206,23 @@
max-width: 42rem;
line-height: 1.4;
}
@media screen and (--breakpoint-min-md) {
.HomeLayout {
margin-right: 346px;
}
.HomeLayout-sidebar {
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 346px;
background-color: #F9FBFC;
border-left: 2px solid var(--border-color);
}
.HomeLayout-mainColumn {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
}
"use strict";
import _ from "underscore";
import { createAction } from "redux-actions";
import moment from "moment";
// HACK: just use our Angular resources for now
function AngularResourceProxy(serviceName, methods) {
methods.forEach((methodName) => {
this[methodName] = function(...args) {
let service = angular.element(document.querySelector("body")).injector().get(serviceName);
return service[methodName](...args).$promise;
}
});
}
// similar to createAction but accepts a (redux-thunk style) thunk and dispatches based on whether
// the promise returned from the thunk resolves or rejects, similar to redux-promise
function createThunkAction(actionType, actionThunkCreator) {
return function(...actionArgs) {
var thunk = actionThunkCreator(...actionArgs);
return async function(dispatch, getState) {
try {
let payload = await thunk(dispatch, getState);
dispatch({ type: actionType, payload });
} catch (error) {
dispatch({ type: actionType, payload: error, error: true });
throw error;
}
}
}
}
// resource wrappers
const ActivityApi = new AngularResourceProxy("Activity", ["list", "recent_views"]);
const CardApi = new AngularResourceProxy("Card", ["list"]);
const MetadataApi = new AngularResourceProxy("Metabase", ["db_list", "db_metadata"]);
// action constants
export const SET_SELECTED_TAB = 'SET_SELECTED_TAB';
export const SET_CARDS_FILTER = 'SET_CARDS_FILTER';
export const FETCH_ACTIVITY = 'FETCH_ACTIVITY';
export const FETCH_CARDS = 'FETCH_CARDS';
export const FETCH_DATABASES = 'FETCH_DATABASES';
export const CLEAR_DATABASE_METADATA = 'CLEAR_DATABASE_METADATA';
export const FETCH_DATABASE_METADATA = 'FETCH_DATABASE_METADATA';
export const FETCH_RECENT_VIEWS = 'FETCH_RECENT_VIEWS';
// action creators
export const setSelectedTab = createAction(SET_SELECTED_TAB);
export const setCardsFilter = createThunkAction(SET_CARDS_FILTER, function(filterDef) {
return function(dispatch, getState) {
let { cardsFilter } = getState();
let { database, table } = filterDef;
if (database && !table && database !== cardsFilter.database) {
// user has picked a database different from any previous choice
dispatch(clearDatabaseMetadata());
dispatch(fetchDatabaseMetadata(database));
dispatch(fetchCards('database', database));
} else if (database && !table && database === cardsFilter.database) {
// user is simply clearing the table selection
dispatch(fetchCards('database', database));
} else if (database && table) {
// user has chosen a specific table to filter on
dispatch(fetchCards('table', table));
} else if (!database && cardsFilter.database) {
// clearing out all filters
dispatch(fetchCards('all'));
}
return filterDef;
};
});
export const fetchActivity = createThunkAction(FETCH_ACTIVITY, function() {
return async function(dispatch, getState) {
let activity = await ActivityApi.list();
for (var ai of activity) {
ai.timestamp = moment(ai.timestamp);
ai.hasLinkableModel = function() {
return (_.contains(["card", "dashboard"], this.model));
};
}
return activity;
};
});
export const fetchRecentViews = createThunkAction(FETCH_RECENT_VIEWS, function() {
return async function(dispatch, getState) {
let recentViews = await ActivityApi.recent_views();
for (var v of recentViews) {
v.timestamp = moment(v.timestamp);
}
return recentViews;
};
});
export const fetchCards = createThunkAction(FETCH_CARDS, function(filterMode, filterModelId) {
return async function(dispatch, getState) {
let cards = await CardApi.list({'filterMode' : filterMode, 'model_id' : filterModelId });
for (var c of cards) {
c.created_at = moment(c.created_at);
c.updated_at = moment(c.updated_at);
c.icon = c.display ? 'illustration_visualization_' + c.display : null;
}
return cards;
};
});
export const fetchDatabases = createThunkAction(FETCH_DATABASES, function() {
return async function(dispatch, getState) {
return await MetadataApi.db_list();
};
});
export const clearDatabaseMetadata = createAction(CLEAR_DATABASE_METADATA);
export const fetchDatabaseMetadata = createThunkAction(FETCH_DATABASE_METADATA, function(database_id) {
return async function(dispatch, getState) {
return await MetadataApi.db_metadata({'dbId': database_id});
};
});
"use strict";
import React, { Component, PropTypes } from "react";
import Icon from "metabase/components/Icon.react";
export default class AccordianItem extends Component {
render() {
let { children, onClickFn, isOpen, itemId, title } = this.props;
return (
<div key={itemId}>
<div className="p2 text-grey-4 text-brand-hover border-bottom" onClick={() => (onClickFn(itemId))}>
<span className="float-left">{title}</span>
<div className="text-right text-grey-2 text-brand-hover">
{ isOpen ?
<Icon name="chevronup" width={12} height={12}></Icon>
:
<Icon name="chevrondown" width={12} height={12}></Icon>
}
</div>
</div>
{ isOpen ?
<div className="pt1">
<div className="article">
{children}
</div>
</div>
: null }
</div>
);
}
}
AccordianItem.propTypes = {
onClickFn: PropTypes.func.isRequired,
itemId: PropTypes.number.isRequired,
isOpen: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
children: PropTypes.element.isRequired
}
'use strict';
import React, { Component, PropTypes } from 'react';
import _ from 'underscore';
import LoadingAndErrorWrapper from 'metabase/components/LoadingAndErrorWrapper.react';
import ActivityItem from './ActivityItem.react';
import ActivityStory from './ActivityStory.react';
import { fetchActivity } from '../actions';
import Urls from 'metabase/lib/urls';
export default class Activity extends Component {
constructor() {
super();
this.state = { error: null, userColors: {} };
this.colorClasses = ['bg-brand', 'bg-purple', 'bg-error', 'bg-green', 'bg-gold', 'bg-grey-2'];
}
async componentDidMount() {
try {
await this.props.dispatch(fetchActivity());
} catch (error) {
this.setState({ error });
}
}
componentWillReceiveProps(nextProps) {
// do a quick pass over the activity and make sure we've assigned colors to all users which have activity
let { activity, user } = nextProps;
let { userColors } = this.state;
const colors = [1,2,3,4,5];
const maxColorUsed = (_.isEmpty(userColors)) ? 0 : _.max(_.values(userColors));
var currColor = (maxColorUsed && maxColorUsed < colors.length) ? maxColorUsed : 0;
for (var item of activity) {
if (!(item.user_id in userColors)) {
// assign the user a color
if (item.user_id === user.id) {
userColors[item.user_id] = 0;
} else if (item.user_id === null) {
// just skip this scenario, we handle this differently
} else {
userColors[item.user_id] = colors[currColor];
currColor++;
// if we hit the end of the colors list then just go back to the beginning again
if (currColor >= colors.length) {
currColor = 0;
}
}
}
}
this.setState({
'error': this.state.error,
'userColors': userColors
});
}
userName(user, currentUser) {
if (user && user.id === currentUser.id) {
return "You";
} else if (user) {
return user.first_name;
} else {
return "Metabase";
}
}
activityDescription(item, user) {
// this is a base to start with
const description = {
userName: this.userName(item.user, user),
subject: "did some super awesome stuff thats hard to describe",
subjectRefLink: null,
subjectRefName: null,
body: null,
bodyLink: null,
timeSince: item.timestamp.fromNow()
};
switch (item.topic) {
case "card-create":
case "card-update":
description.subject = "saved a question about";
description.subjectRefLink = Urls.tableRowsQuery(item.database_id, item.table_id);
description.subjectRefName = item.table.display_name;
description.body = item.details.name;
description.bodyLink = (item.model_exists) ? Urls.modelToUrl(item.model, item.model_id) : null;
break;
case "card-delete":
description.subject = "deleted a question";
description.body = item.details.name;
break;
case "dashboard-create":
description.subject = "created a dashboard";
description.body = item.details.name;
description.bodyLink = (item.model_exists) ? Urls.modelToUrl(item.model, item.model_id) : null;
break;
case "dashboard-delete":
description.subject = "deleted a dashboard";
description.body = item.details.name;
break;
case "dashboard-add-cards":
description.subject = "added a question to the dashboard -";
description.subjectRefLink = (item.model_exists) ? Urls.dashboard(item.model_id) : null;
description.subjectRefName = item.details.name;
description.body = item.details.dashcards[0].name;
description.bodyLink = Urls.card(item.details.dashcards[0].card_id);
break;
case "dashboard-remove-cards":
description.subject = "removed a question from the dashboard -";
description.subjectRefLink = (item.model_exists) ? Urls.dashboard(item.model_id) : null;
description.subjectRefName = item.details.name;
description.body = item.details.dashcards[0].name;
description.bodyLink = Urls.card(item.details.dashcards[0].card_id);
break;
case "database-sync":
description.subject = "received the latest data from";
description.subjectRefName = item.database.name;
break;
case "user-joined":
description.subject = "joined the party!";
break;
};
return description;
}
initialsCssClasses(user) {
let { userColors } = this.state;
if (user) {
const userColorIndex = userColors[user.id];
const colorCssClass = this.colorClasses[userColorIndex];
return colorCssClass;
}
}
render() {
let { activity, user } = this.props;
let { error } = this.state;
return (
<LoadingAndErrorWrapper loading={!activity} error={error}>
{() =>
<div className="full flex flex-column">
<div className="">
{ activity.length === 0 ?
<div className="flex flex-column layout-centered mt4">
<span className="QuestionCircle">!</span>
<div className="text-normal mt3 mb1">Hmmm, looks like nothing has happened yet.</div>
<div className="text-normal text-grey-2">Save a question and get this baby going!</div>
</div>
:
<ul className="pt2 pb4 relative">
{activity.map(item =>
<li key={item.id} className="mt3">
<ActivityItem
item={item}
description={this.activityDescription(item, user)}
userColors={this.initialsCssClasses(item.user)}
/>
<ActivityStory story={this.activityDescription(item, item.user)} />
</li>
)}
</ul>
}
</div>
</div>
}
</LoadingAndErrorWrapper>
);
}
}
Activity.propTypes = {
dispatch: PropTypes.func.isRequired,
user: PropTypes.object.isRequired,
activity: PropTypes.array.isRequired
}
'use strict';
import React, { Component, PropTypes } from 'react';
import Icon from 'metabase/components/Icon.react';
import IconBorder from 'metabase/components/IconBorder.react';
import UserAvatar from 'metabase/components/UserAvatar.react';
export default class ActivityItem extends Component {
render() {
const { item, description, userColors } = this.props;
return (
<div className="ml1 flex align-center mr2">
<span>
{ item.user ?
<UserAvatar user={item.user} background={userColors} style={{color: '#fff', borderWidth: '0'}}/>
:
<IconBorder style={{color: '#B8C0C8'}}>
<Icon name='sync' width={16} height={16} />
</IconBorder>
}
</span>
<div className="ml2 full flex align-center">
<div className="text-grey-4">
<span className="text-dark">{description.userName}</span>
&nbsp;{description.subject}&nbsp;
{ description.subjectRefName && description.subjectRefLink ?
<a className="link text-dark" href={description.subjectRefLink}>{description.subjectRefName}</a>
: null }
{ description.subjectRefName && !description.subjectRefLink ?
<span className="text-dark">{description.subjectRefName}</span>
: null }
</div>
<div className="flex-align-right text-right text-grey-2">
{description.timeSince}
</div>
</div>
</div>
)
}
}
ActivityItem.propTypes = {
item: PropTypes.object.isRequired,
description: PropTypes.object.isRequired,
userColors: PropTypes.string
}
'use strict';
import React, { Component, PropTypes } from 'react';
export default class ActivityStory extends Component {
constructor(props) {
super(props);
this.styles = {
borderWidth: '2px',
borderColor: '#DFE8EA',
}
}
render() {
const { story } = this.props;
if (!story.body) {
return null;
}
return (
<div className="mt1 border-left flex mr2" style={{borderWidth: '3px', marginLeft: '22px', borderColor: '#F2F5F6'}}>
<div className="flex full ml4 bordered rounded p2" style={this.styles}>
{ story.bodyLink ?
<a className="link" href={story.bodyLink}>{story.body}</a>
:
<span>{story.body}</span>
}
</div>
</div>
)
}
}
ActivityStory.propTypes = {
story: PropTypes.object.isRequired
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment