Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/logs paging active #318

Merged
merged 13 commits into from
May 24, 2018
Merged
138 changes: 90 additions & 48 deletions src/DataSets/DataSets.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import HelpOutlineIcon from 'material-ui/svg-icons/action/help-outline';
import ListIcon from 'material-ui/svg-icons/action/list';
import FormHelpers from '../forms/FormHelpers';
import {currentUserHasAdminRole, canCreate} from '../utils/Dhis2Helpers';
import * as sharing from '../models/Sharing';

const {SimpleCheckBox} = FormHelpers;

Expand Down Expand Up @@ -73,8 +74,8 @@ const DataSets = React.createClass({
};
},

tr(text, variables={}) {
return this.getTranslation(text, variables); // so it's less verbose
tr(text, namespace = {}) {
return this.getTranslation(text, namespace);
},

getInitialState() {
Expand All @@ -90,7 +91,12 @@ const DataSets = React.createClass({
orgUnits: null,
helpOpen: false,
logs: null,
}
logsHasMore: null,
logsFilter: log => true,
logsPageLast: 0,
logsOldestDate: null,
sharing: null,
};
},


Expand All @@ -102,7 +108,7 @@ const DataSets = React.createClass({
this.registerDisposable(deleteStore.subscribe(deleteObjects => this.getDataSets()));
this.registerDisposable(this.subscribeToModelStore(sharingStore, "sharing"));
this.registerDisposable(this.subscribeToModelStore(orgUnitsStore, "orgUnits"));
this.registerDisposable(logsStore.subscribe(datasets => this.showDatasetsLogs(datasets)));
this.registerDisposable(logsStore.subscribe(datasets => this.openLogs(datasets)));
},

subscribeToModelStore(store, modelName) {
Expand All @@ -122,7 +128,7 @@ const DataSets = React.createClass({
const filteredDataSets =
searchValue ? allDataSets.filter().on('displayName').ilike(searchValue) : allDataSets;
const order = sorting ? sorting.join(":") : undefined;
const fields = "id,name,displayName,shortName,created,lastUpdated,publicAccess,user,access"
const fields = "id,name,displayName,shortName,created,lastUpdated,externalAccess,publicAccess,userAccesses,userGroupAccesses,user,access"

filteredDataSets.list({order, fields}).then(da => {
this.setState({
Expand All @@ -133,26 +139,6 @@ const DataSets = React.createClass({
});
},

showDatasetsLogs(datasets) {
// Set this.state.logs to the logs that include any of the given
// datasets, and this.state.logsObject to a description of their contents.
if (!datasets) {
this.setState({logsObject: null});
} else {
const title = this.tr("logs") + " (" + datasets.map(ds => ds.id).join(", ") + ")";
this.setState({
logsObject: title,
logs: null,
});
getLogs().then(logs => {
const idsSelected = new Set(datasets.map(ds => ds.id));
const hasIds = (log) => log.datasets.some(ds => idsSelected.has(ds.id));
const logsSelected = _(logs).filter(hasIds).orderBy('date', 'desc').value();
this.setState({logs: logsSelected});
});
}
},

searchListByName(searchObserver) {

//bind key search listener
Expand All @@ -175,26 +161,60 @@ const DataSets = React.createClass({
this.setState({settingsOpen: false});
},

openLogs() {
// Retrieve the logs and save them in this.state.logs, and set
// this.state.logsObject to a description of their contents.
const title = this.tr("logs") + " (" + this.tr("all") + ")";
openAllLogs() {
const title = `${this.tr("logs")} (${this.tr("all")})`;
this.setState({
logsFilter: log => true,
logsObject: title,
logs: null,
});
getLogs().then(logs => {
this.setState({logs: _(logs).orderBy('date', 'desc').value()});
});
this.addLogs([0, 1]);
},

addLogPage() {
// Placeholder for a function that will add another page to
// the already stored logs.
getLogs([-2]).then(logs => {
// TODO: instead of "-2", get the page previous to the last one retrieved.
logs = [].concat(this.state.logs, logs);
this.setState({logs: _(logs).orderBy('date', 'desc').value()});
openLogs(datasets) {
// Set this.state.logs to the logs that include any of the given
// datasets, this.state.logsObject to a description of their contents
// and this.state.logsFilter so it selects only the relevant logs.
if (datasets === null) {
this.setState({logsObject: null});
} else {
const ids = datasets.map(ds => ds.id);
const idsSet = new Set(ids);
const title = `${this.tr("logs")} (${ids.join(", ")})`;
const logsFilter = log => log.datasets.some(ds => idsSet.has(ds.id));

this.setState({
logsObject: title,
logs: null,
logsFilter,
});
this.addLogs([0, 1]); // load the last two log pages
}
},

addNextLog() {
return this.addLogs([this.state.logsPageLast + 1]);
},

addLogs(pages) {
return getLogs(pages).then(res => {
if (res === null) {
this.setState({
logsPageLast: -1,
logs: this.state.logs || [],
});
} else {
const {logs, hasMore: logsHasMore} = res;
const logsOldestDate = logs.length > 0 ? logs[logs.length - 1].date : null;
const filteredLogs = _(logs).filter(this.state.logsFilter).value();

this.setState({
logsHasMore: logsHasMore,
logs: _([this.state.logs, filteredLogs]).compact().flatten().value(),
logsPageLast: _.max(pages),
logsOldestDate: logsOldestDate,
});
}
});
},

Expand Down Expand Up @@ -233,6 +253,16 @@ const DataSets = React.createClass({
this.setState({helpOpen: false});
},

_onSharingClose(sharings) {
const {updated, all} = sharing.getChanges(this.state.dataRows, sharings);

if (!_(updated).isEmpty()) {
log('change sharing settings', 'success', updated);
this.setState({dataRows: all});
}
listActions.hideSharingBox();
},

render() {
const currentlyShown = calculatePageValue(this.state.pager);

Expand Down Expand Up @@ -311,14 +341,24 @@ const DataSets = React.createClass({

const renderLogsButton = () => (
<div style={{float: 'right'}}>
<IconButton tooltip={this.tr("logs")} onClick={this.openLogs}>
<IconButton tooltip={this.tr("logs")} onClick={this.openAllLogs}>
<ListIcon />
</IconButton>
</div>
);

const {d2} = this.context;
const userCanCreateDataSets = canCreate(d2);
const { logsPageLast, logsOldestDate, logsHasMore } = this.state;
const olderLogLiteral = logsPageLast < 0 ? this.tr("logs_no_older") : this.tr("logs_older");
const dateString = new Date(logsOldestDate || Date()).toLocaleString();
const label = olderLogLiteral + " " + dateString;

const logLoadMoreButton = logsHasMore ? (
<FlatButton
Copy link
Collaborator

@tokland tokland May 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question about the UX, @adrianq : does it make sense to move this action inside the logs box, as a center button? this way the user only sees it when it really makes sense.

label={label}
onClick={this.addNextLog}
/>
) : null;

const logActions = [
<FlatButton
Expand All @@ -328,8 +368,9 @@ const DataSets = React.createClass({
];

const renderLogs = () => {
const logs = this.state.logs; // shortcut
if (logs === null)
const { logs } = this.state;

if (!logs)
return this.tr("logs_loading");
else if (_(logs).isEmpty())
return this.tr("logs_none");
Expand Down Expand Up @@ -375,9 +416,7 @@ const DataSets = React.createClass({
{this.state.sharing ? <SharingDialog
objectsToShare={this.state.sharing.models}
open={true}
onRequestClose={() => {
log('change sharing settings', 'success', this.state.sharing.models);
listActions.hideSharingBox();}}
onRequestClose={this._onSharingClose}
onError={err => {
log('change sharing settings', 'failed', this.state.sharing.models);
snackActions.show({message: err && err.message || 'Error'});}}
Expand Down Expand Up @@ -433,12 +472,15 @@ const DataSets = React.createClass({
onRequestClose={listActions.hideLogs}
autoScrollBodyContent={true}
>
{ renderLogs() }
{renderLogs()}
<div style={{textAlign: "center"}}>
{logLoadMoreButton}
</div>
</Dialog>)
: null }
</div>

{userCanCreateDataSets && <ListActionBar route="datasets/add" />}
{canCreate(d2) && <ListActionBar route="datasets/add" />}
</div>
);
},
Expand Down
105 changes: 73 additions & 32 deletions src/DataSets/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,12 @@ import { getInstance as getD2 } from 'd2/lib/d2';
const maxLogsPerPage = 200; // TODO: maybe make it readable from the dataStore
const maxLogPages = 100;


async function log(actionName, status, datasets) {
// Log the name of the action that has been executed, its status
// ("success", "failed"), by whom and on which datasets.
const store = await getStore();
const logs = await getLogs([0], store);
const logs = ((await getLogs([0])) || {logs: []}).logs;
logs.push(await makeEntry(actionName, status, datasets));
return setLogs(logs, store);
}

async function getStore() {
const d2 = await getD2();
return d2.dataStore.get('dataset-configuration');
return setLogs(logs);
}

async function makeEntry(actionName, status, datasets) {
Expand All @@ -39,41 +32,89 @@ async function makeEntry(actionName, status, datasets) {
};
}

async function getLogs(pages, store) {
async function getLogs(pages = [0, 1]) {
// Return the concatenated logs for the given pages (relative to
// logsPageCurrent) if they exist, or an empty list otherwise.
pages = pages || [-1, 0]; // by default, take the last two pages
store = store ? store : await getStore();
const logsPageCurrent = await store.get('logs-page-current').catch(() => 0);
const pagesNames = pages.map(n => 'logs-page-' + mod(logsPageCurrent + n, maxLogPages));
const logs = await Promise.all(pagesNames.map(name => store.get(name).catch(() => [])));
return _.flatten(logs);
// currentPage) if they exist, or an empty list otherwise.
// It returns null when there are no more log pages.
if (pages.every(n => n >= maxLogPages))
return null;

const store = await getStore();
const currentPage = await getCurrentPage(store);
const pagesNames = pages.map(n => 'logs-page-' + mod(currentPage - n, maxLogPages));
const storeKeys = new Set(await store.getKeys());
const existingPageNames = pagesNames.filter(name => storeKeys.has(name));

if (_(existingPageNames).isEmpty()) {
return null;
} else {
const existingPages = _(Array.from(storeKeys))
.map(key => key.match(/^logs-page-(\d+)$/))
.map(match => match ? parseInt(match[1]) : null)
.reject(_.isNull)
.value();
const nextPage = _.max(pages) + 1;
const nextPageIndex = mod(currentPage - nextPage, maxLogPages);
const hasMore = (
nextPage < maxLogPages &&
existingPages.includes(nextPageIndex)
);
const logs = await Promise.all(existingPageNames.map(name => store.get(name)));
const orderedLogs = _(logs).filter().flatten().orderBy('date', 'desc').value();

return {logs: orderedLogs, hasMore};
}
}

async function setLogs(logs, store) {
// Save the given list of logs in the current page index and
// update the page index.
const logsPageCurrent = await store.get('logs-page-current').catch(() => 0);
function getCurrentPage(store) {
return store.get('logs-page-current').catch(err => {
if (err.httpStatusCode === 404) {
return store.set('logs-page-current', 0).then(() => 0);
} else {
throw err;
}
});
}

async function setLogs(logs) {
// Save the given list of logs in the current page index and update the page index.
const store = await getStore();
const currentPage = await getCurrentPage(store);
const nextCurrentPage = logs.length < maxLogsPerPage ? currentPage : mod(currentPage + 1, maxLogPages);

return Promise.all([
store.set('logs-page-' + logsPageCurrent, logs),
store.set('logs-page-current', logs.length < maxLogsPerPage ?
logsPageCurrent : mod(logsPageCurrent + 1, maxLogPages)),
]);
store.set('logs-page-' + currentPage, logs),
...(nextCurrentPage === currentPage ? [] : [
store.set('logs-page-current', nextCurrentPage),
store.set(`logs-page-${nextCurrentPage}`, []),
]),
]);
}

async function getStore() {
const d2 = await getD2();
return d2.dataStore.get('dataset-configuration');
}

function mod(n, m) {
return ((n % m) + m) % m; // the modulo operation can be negative otherwise...
}

function formatDate(isoDate) {
return new Date(isoDate).toLocaleString();
}

// Simple component to show a log entry.
function LogEntry(props) {
return (<div key={props.date} style={{paddingBottom: "15px"}}>
<b>Date:</b> {props.date} <br />
<b>Action:</b> {props.action} <br />
<b>Status:</b> {props.status} <br />
<b>User:</b> {props.user.displayName} ({props.user.username})<br />
<b>Datasets:</b> {props.datasets.map(ds => `${ds.displayName} (${ds.id}) `).join(', ')} <br />
</div>);
return (
<div key={props.date} style={{paddingBottom: "15px"}}>
<b>Date:</b> {formatDate(props.date)} <br />
<b>Action:</b> {props.action} <br />
<b>Status:</b> {props.status} <br />
<b>User:</b> {props.user.displayName} ({props.user.username})<br />
<b>Datasets:</b> {props.datasets.map(ds => `${ds.displayName} (${ds.id})`).join(', ')} <br />
</div>
);
}


Expand Down
2 changes: 2 additions & 0 deletions src/i18n/i18n_module_ar.properties
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ help_landing_page=
logs=Logs
logs_none=No logs
logs_loading=Loading logs...
logs_older=Load logs older than
logs_no_older=No logs older than
2 changes: 2 additions & 0 deletions src/i18n/i18n_module_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ help_landing_page=
logs=Logs
logs_none=No logs
logs_loading=Loading logs...
logs_older=Load logs older than
logs_no_older=No logs older than
2 changes: 2 additions & 0 deletions src/i18n/i18n_module_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ help_landing_page=
logs=Logs
logs_none=Ningún registro
logs_loading=Cargando logs...
logs_older=Cargar logs anteriores a
logs_no_older=No hay logs anteriores a
2 changes: 2 additions & 0 deletions src/i18n/i18n_module_fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ help_landing_page=
logs=Logs
logs_none=No logs
logs_loading=Loading logs...
logs_older=Load logs older than
logs_no_older=No logs older than
Loading