From f37b2ff60ca89b8ea22613cfe80007ab5db4b9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Ka=C5=88ka?= Date: Sun, 10 Nov 2024 17:54:25 +0100 Subject: [PATCH] [Enhancement #520] Add change history filtering for term and vocabulary details. --- src/action/AsyncActions.ts | 37 ++++- src/action/AsyncVocabularyActions.ts | 19 +-- src/component/changetracking/AssetHistory.tsx | 127 +++++++++++++----- src/component/changetracking/DeleteRow.tsx | 35 +++++ .../VocabularyContentDeleteRow.tsx | 6 +- .../vocabulary/TermChangeFrequencyUI.tsx | 12 +- .../VocabularyContentChangeFilterData.ts | 16 +++ 7 files changed, 195 insertions(+), 57 deletions(-) create mode 100644 src/component/changetracking/DeleteRow.tsx diff --git a/src/action/AsyncActions.ts b/src/action/AsyncActions.ts index 6db20ef6..ae6dc891 100644 --- a/src/action/AsyncActions.ts +++ b/src/action/AsyncActions.ts @@ -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 @@ -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( data, diff --git a/src/action/AsyncVocabularyActions.ts b/src/action/AsyncVocabularyActions.ts index 030a5aa7..a827996a 100644 --- a/src/action/AsyncVocabularyActions.ts +++ b/src/action/AsyncVocabularyActions.ts @@ -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"; @@ -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) { @@ -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 diff --git a/src/component/changetracking/AssetHistory.tsx b/src/component/changetracking/AssetHistory.tsx index 6c3b3968..d4883032 100644 --- a/src/component/changetracking/AssetHistory.tsx +++ b/src/component/changetracking/AssetHistory.tsx @@ -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"; @@ -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; @@ -23,8 +30,30 @@ export const AssetHistory: React.FC = ({ asset }) => { const { i18n } = useI18n(); const dispatch: ThunkDispatch = useDispatch(); const [records, setRecords] = React.useState(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) && @@ -35,53 +64,89 @@ export const AssetHistory: React.FC = ({ 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 ; } - if (records.length === 0) { - return ( -
- {i18n("history.empty")} -
- ); - } + return (
- - - - - + + + + + + + + + + - {records.map((r) => - r instanceof UpdateRecord ? ( - - ) : ( - - ) - )} + {records.map((r) => { + if (r instanceof PersistRecord) { + return ; + } + if (r instanceof UpdateRecord) { + return ; + } + if (r instanceof DeleteRecord) { + return ; + } + return null; + })}
{i18n("history.whenwho")}{i18n("history.type")}{i18n("history.changedAttribute")}{i18n("history.originalValue")}{i18n("history.newValue")}{i18n("history.whenwho")}{i18n("history.type")}{i18n("history.changedAttribute")}{i18n("history.originalValue")}{i18n("history.newValue")}
+ setFilterAuthor(e.target.value)} + /> + + + + setFilterAttribute(e.target.value)} + /> +
diff --git a/src/component/changetracking/DeleteRow.tsx b/src/component/changetracking/DeleteRow.tsx new file mode 100644 index 00000000..981c1440 --- /dev/null +++ b/src/component/changetracking/DeleteRow.tsx @@ -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 = (props) => { + const { i18n } = useI18n(); + const record = props.record; + const created = new Date(Date.parse(record.timestamp)); + return ( + + +
+ +
+
+ {record.author.fullName} +
+ + + {i18n(record.typeLabel)} + + + + + + ); +}; + +export default DeleteRow; diff --git a/src/component/changetracking/VocabularyContentDeleteRow.tsx b/src/component/changetracking/VocabularyContentDeleteRow.tsx index 55205364..9a77aa02 100644 --- a/src/component/changetracking/VocabularyContentDeleteRow.tsx +++ b/src/component/changetracking/VocabularyContentDeleteRow.tsx @@ -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 = (props) => { const { i18n } = useI18n(); diff --git a/src/component/vocabulary/TermChangeFrequencyUI.tsx b/src/component/vocabulary/TermChangeFrequencyUI.tsx index e5468669..740cff12 100644 --- a/src/component/vocabulary/TermChangeFrequencyUI.tsx +++ b/src/component/vocabulary/TermChangeFrequencyUI.tsx @@ -93,7 +93,7 @@ const TermChangeFrequencyUI: React.FC = ({ useEffect(() => { applyFilterDebounced({ - author: filterTerm, + author: filterAuthor, term: filterTerm, type: filterType, attribute: filterAttribute, @@ -190,11 +190,11 @@ const TermChangeFrequencyUI: React.FC = ({ {i18n("history.whenwho")} {i18n("type.term")} - {i18n("history.type")} + {i18n("history.type")} {i18n("history.changedAttribute")} - + = ({ onChange={(e) => setFilterAuthor(e.target.value)} /> - + = ({ onChange={(e) => setFilterTerm(e.target.value)} /> - + - +