Skip to content

Commit

Permalink
[Enhancement #520] Add change history filtering for term and vocabula…
Browse files Browse the repository at this point in the history
…ry details.
  • Loading branch information
lukaskabc committed Nov 10, 2024
1 parent 8ff0138 commit f37b2ff
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 57 deletions.
37 changes: 35 additions & 2 deletions src/action/AsyncActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ import UserRole, { UserRoleData } from "../model/UserRole";
import { loadTermCount } from "./AsyncVocabularyActions";
import { getApiPrefix } from "./ActionUtils";
import { getShortLocale } from "../util/IntlUtil";
import {
getChangeTypeUri,
VocabularyContentChangeFilterData,
} from "../model/filter/VocabularyContentChangeFilterData";
/*
* Asynchronous actions involve requests to the backend server REST API. As per recommendations in the Redux docs, this consists
* of several synchronous sub-actions which inform the application of initiation of the request and its result.
*
* Some conventions (they are also described in README.md):
* API guidelines:
* _Load_ - use IRI identifiers as parameters (+ normalized name as string if necessary, e.g. when fetching a term).
* _Create_ - use the instance to be created as parameter + IRI identifier if additional context is necessary (e.g. when creating a term).
* _Update_ - use the instance to be updated as parameter. It should contain all the necessary data.
* _Remove_ - use the instance to be removed as parameter.
*
* Naming conventions for CRUD operations:
* _load${ASSET(S)}_ - loading assets from the server, e.g. `loadVocabulary`
* _create${ASSET}_ - creating an asset, e.g. `createVocabulary`
* _update${ASSET}_ - updating an asset, e.g. `updateVocabulary`
* _remove${ASSET}_ - removing an asset, e.g. `removeVocabulary`
*
* TODO Consider splitting this file into multiple, it is becoming too long
*/

/*
* Asynchronous actions involve requests to the backend server REST API. As per recommendations in the Redux docs, this consists
Expand Down Expand Up @@ -1130,13 +1153,23 @@ export function loadLatestTextAnalysisRecord(resourceIri: IRI) {
};
}

export function loadHistory(asset: Asset) {
export function loadHistory(
asset: Asset,
filterData?: VocabularyContentChangeFilterData
) {
const assetIri = VocabularyUtils.create(asset.iri);
const historyConf = resolveHistoryLoadingParams(asset, assetIri);
const action = { type: historyConf.actionType };
return (dispatch: ThunkDispatch) => {
dispatch(asyncActionRequest(action, true));
return Ajax.get(historyConf.url, param("namespace", assetIri.namespace))
let params = param("namespace", assetIri.namespace);
if (filterData) {
for (const [key, value] of Object.entries(filterData)) {
params = params.param(key, value);
}
params = params.param("type", getChangeTypeUri(filterData));
}
return Ajax.get(historyConf.url, params)
.then((data) =>
JsonLdUtils.compactAndResolveReferencesAsArray<ChangeRecordData>(
data,
Expand Down
19 changes: 6 additions & 13 deletions src/action/AsyncVocabularyActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
publishMessage,
publishNotification,
} from "./SyncActions";
import VocabularyUtils, { IRI } from "../util/VocabularyUtils";
import { IRI } from "../util/VocabularyUtils";
import ActionType from "./ActionType";
import Ajax, { param } from "../util/Ajax";
import Constants from "../util/Constants";
Expand All @@ -31,7 +31,10 @@ import ChangeRecord, {
CONTEXT as CHANGE_RECORD_CONTEXT,
} from "../model/changetracking/ChangeRecord";
import AssetFactory from "../util/AssetFactory";
import { VocabularyContentChangeFilterData } from "../model/filter/VocabularyContentChangeFilterData";
import {
getChangeTypeUri,
VocabularyContentChangeFilterData,
} from "../model/filter/VocabularyContentChangeFilterData";
import { getLocalized } from "../model/MultilingualString";

export function loadTermCount(vocabularyIri: IRI) {
Expand Down Expand Up @@ -154,17 +157,7 @@ export function loadVocabularyContentDetailedChanges(
for (const [key, value] of Object.entries(filterData)) {
params = params.param(key, value);
}
switch (params.getParams()?.["type"]) {
case "history.type.persist":
params = params.param("type", VocabularyUtils.PERSIST_EVENT);
break;
case "history.type.update":
params = params.param("type", VocabularyUtils.UPDATE_EVENT);
break;
case "history.type.delete":
params = params.param("type", VocabularyUtils.DELETE_EVENT);
break;
}
params = params.param("type", getChangeTypeUri(filterData));
return Ajax.get(
`${Constants.API_PREFIX}/vocabularies/${vocabularyIri.fragment}/history-of-content/detail`,
params
Expand Down
127 changes: 96 additions & 31 deletions src/component/changetracking/AssetHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import { useCallback, useState } from "react";
import Asset, { AssetData } from "../../model/Asset";
import ChangeRecord from "../../model/changetracking/ChangeRecord";
import { Table } from "reactstrap";
Expand All @@ -14,6 +15,12 @@ import Constants from "../../util/Constants";
import { useI18n } from "../hook/useI18n";
import Vocabulary from "../../model/Vocabulary";
import Term from "../../model/Term";
import CustomInput from "../misc/CustomInput";
import Select from "../misc/Select";
import DeleteRecord from "../../model/changetracking/DeleteRecord";
import DeleteRow from "./DeleteRow";
import { debounce } from "lodash";
import { VocabularyContentChangeFilterData } from "../../model/filter/VocabularyContentChangeFilterData";

interface AssetHistoryProps {
asset: Asset;
Expand All @@ -23,8 +30,30 @@ export const AssetHistory: React.FC<AssetHistoryProps> = ({ asset }) => {
const { i18n } = useI18n();
const dispatch: ThunkDispatch = useDispatch();
const [records, setRecords] = React.useState<null | ChangeRecord[]>(null);
const [filterAuthor, setFilterAuthor] = useState("");
const [filterType, setFilterType] = useState("");
const [filterAttribute, setFilterAttribute] = useState("");
const loadHistoryActionDebounced = useCallback(
debounce(
(
asset: Asset,
filterData: VocabularyContentChangeFilterData,
cb: (records?: ChangeRecord[]) => void
) => dispatch(loadHistoryAction(asset, filterData)).then(cb),
Constants.INPUT_DEBOUNCE_WAIT_TIME
),
[dispatch]
);

React.useEffect(() => {
if (asset.iri !== Constants.EMPTY_ASSET_IRI) {
const filter = {
author: filterAuthor,
term: "",
type: filterType,
attribute: filterAttribute,
};

//Check if vocabulary/term is a snapshot
if (
(asset instanceof Term || asset instanceof Vocabulary) &&
Expand All @@ -35,53 +64,89 @@ export const AssetHistory: React.FC<AssetHistoryProps> = ({ asset }) => {
types: asset.types,
};
const snapshotTimeCreated = Date.parse(asset.snapshotCreated()!);
dispatch(loadHistoryAction(modifiedAsset as Asset)).then((recs) => {
//Show history which is relevant to the snapshot
const filteredRecs = recs.filter(
(r) => Date.parse(r.timestamp) < snapshotTimeCreated
);
setRecords(filteredRecs);
loadHistoryActionDebounced(modifiedAsset as Asset, filter, (recs) => {
if (recs) {
//Show history which is relevant to the snapshot
const filteredRecs = recs.filter(
(r) => Date.parse(r.timestamp) < snapshotTimeCreated
);
setRecords(filteredRecs);
}
});
} else {
dispatch(loadHistoryAction(asset)).then((recs) => {
setRecords(recs);
loadHistoryActionDebounced(asset, filter, (recs) => {
if (recs) {
setRecords(recs);
}
});
}
}
}, [asset, dispatch]);
}, [asset, dispatch, filterAuthor, filterType, filterAttribute]);
if (!records) {
return <ContainerMask text={i18n("history.loading")} />;
}
if (records.length === 0) {
return (
<div
id="history-empty-notice"
className="additional-metadata-container italics"
>
{i18n("history.empty")}
</div>
);
}

return (
<div className="additional-metadata-container">
<Table striped={true} responsive={true}>
<thead>
<tr>
<th className="col-xs-2">{i18n("history.whenwho")}</th>
<th className="col-xs-1">{i18n("history.type")}</th>
<th className="col-xs-2">{i18n("history.changedAttribute")}</th>
<th className="col-xs-2">{i18n("history.originalValue")}</th>
<th className="col-xs-2">{i18n("history.newValue")}</th>
<th className="col-2">{i18n("history.whenwho")}</th>
<th className="col-1">{i18n("history.type")}</th>
<th className="col-2">{i18n("history.changedAttribute")}</th>
<th className="col-2">{i18n("history.originalValue")}</th>
<th className="col-2">{i18n("history.newValue")}</th>
</tr>
<tr>
<td>
<CustomInput
name={i18n("asset.author")}
placeholder={i18n("asset.author")}
value={filterAuthor}
onChange={(e) => setFilterAuthor(e.target.value)}
/>
</td>
<td>
<Select
placeholder={i18n("history.type")}
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
>
<option value={""}></option>
{[
"history.type.persist",
"history.type.update",
"history.type.delete",
].map((type) => (
<option key={type} value={type}>
{i18n(type)}
</option>
))}
</Select>
</td>
<td>
<CustomInput
name={i18n("history.changedAttribute")}
placeholder={i18n("history.changedAttribute")}
value={filterAttribute}
onChange={(e) => setFilterAttribute(e.target.value)}
/>
</td>
</tr>
</thead>
<tbody>
{records.map((r) =>
r instanceof UpdateRecord ? (
<UpdateRow key={r.iri} record={r as UpdateRecord} />
) : (
<PersistRow key={r.iri} record={r as PersistRecord} />
)
)}
{records.map((r) => {
if (r instanceof PersistRecord) {
return <PersistRow key={r.iri} record={r} />;
}
if (r instanceof UpdateRecord) {
return <UpdateRow key={r.iri} record={r} />;
}
if (r instanceof DeleteRecord) {
return <DeleteRow key={r.iri} record={r} />;
}
return null;
})}
</tbody>
</Table>
</div>
Expand Down
35 changes: 35 additions & 0 deletions src/component/changetracking/DeleteRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from "react";
import { FormattedDate, FormattedTime } from "react-intl";
import { Badge } from "reactstrap";
import { useI18n } from "../hook/useI18n";
import DeleteRecord from "../../model/changetracking/DeleteRecord";

export interface DeleteRowProps {
record: DeleteRecord;
}

export const DeleteRow: React.FC<DeleteRowProps> = (props) => {
const { i18n } = useI18n();
const record = props.record;
const created = new Date(Date.parse(record.timestamp));
return (
<tr>
<td>
<div>
<FormattedDate value={created} /> <FormattedTime value={created} />
</div>
<div className="italics last-edited-message ml-2">
{record.author.fullName}
</div>
</td>
<td>
<Badge color="danger">{i18n(record.typeLabel)}</Badge>
</td>
<td />
<td />
<td />
</tr>
);
};

export default DeleteRow;
6 changes: 1 addition & 5 deletions src/component/changetracking/VocabularyContentDeleteRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ import { FormattedDate, FormattedTime } from "react-intl";
import { Badge } from "reactstrap";
import { useI18n } from "../hook/useI18n";
import TermIriLink from "../term/TermIriLink";
import DeleteRecord from "../../model/changetracking/DeleteRecord";

export interface DeleteRowProps {
record: DeleteRecord;
}
import { DeleteRowProps } from "./DeleteRow";

export const VocabularyContentDeleteRow: React.FC<DeleteRowProps> = (props) => {
const { i18n } = useI18n();
Expand Down
12 changes: 6 additions & 6 deletions src/component/vocabulary/TermChangeFrequencyUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const TermChangeFrequencyUI: React.FC<TermChangeFrequencyUIProps> = ({

useEffect(() => {
applyFilterDebounced({
author: filterTerm,
author: filterAuthor,
term: filterTerm,
type: filterType,
attribute: filterAttribute,
Expand Down Expand Up @@ -190,27 +190,27 @@ const TermChangeFrequencyUI: React.FC<TermChangeFrequencyUIProps> = ({
<tr>
<th className="col-3">{i18n("history.whenwho")}</th>
<th className="col-3">{i18n("type.term")}</th>
<th className="col-1">{i18n("history.type")}</th>
<th className="col-2">{i18n("history.type")}</th>
<th className="col">{i18n("history.changedAttribute")}</th>
</tr>
<tr>
<td className="col-3">
<td>
<CustomInput
name={i18n("asset.author")}
placeholder={i18n("asset.author")}
value={filterAuthor}
onChange={(e) => setFilterAuthor(e.target.value)}
/>
</td>
<td className="col-3">
<td>
<CustomInput
name={i18n("type.term")}
placeholder={i18n("type.term")}
value={filterTerm}
onChange={(e) => setFilterTerm(e.target.value)}
/>
</td>
<td className={"col-2"}>
<td>
<Select
placeholder={i18n("history.type")}
value={filterType}
Expand All @@ -228,7 +228,7 @@ const TermChangeFrequencyUI: React.FC<TermChangeFrequencyUIProps> = ({
))}
</Select>
</td>
<td className="col-2">
<td>
<CustomInput
name={i18n("history.changedAttribute")}
placeholder={i18n("history.changedAttribute")}
Expand Down
16 changes: 16 additions & 0 deletions src/model/filter/VocabularyContentChangeFilterData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import VocabularyUtils from "../../util/VocabularyUtils";

export interface VocabularyContentChangeFilterData {
author: string;
term: string;
type: string;
attribute: string;
}

export function getChangeTypeUri(
filterData: VocabularyContentChangeFilterData
): string {
switch (filterData.type) {
case "history.type.persist":
return VocabularyUtils.PERSIST_EVENT;
case "history.type.update":
return VocabularyUtils.UPDATE_EVENT;
case "history.type.delete":
return VocabularyUtils.DELETE_EVENT;
}
return "";
}

0 comments on commit f37b2ff

Please sign in to comment.