diff --git a/NEWS.cs.md b/NEWS.cs.md index 54aeeb94..3bb50949 100644 --- a/NEWS.cs.md +++ b/NEWS.cs.md @@ -1,3 +1,10 @@ +#### Verze 3.4.0 + +- Přidána podpora pro záznamy o smazání pojmů. Ty jsou zobrazovány i v grafu aktivity slovníku. +- Přidána možnost filtrovat v historii změn. +- Přidána podpora pro ukládání a anotaci souborů v různých jazycích v rámci jednoho slovníku. +- Přidána podpora pro import překladů pojmů ze souboru MS Excel. + #### Verze 3.3.0 - Přidána možnost stáhnout anotovaný soubor bez nepotvrzených výskytů. diff --git a/NEWS.en.md b/NEWS.en.md index d48dc975..428a4a6e 100644 --- a/NEWS.en.md +++ b/NEWS.en.md @@ -1,3 +1,10 @@ +#### Version 3.4.0 + +- Added support for term deletion records. Term deletion events are also displayed in the vocabulary activity diagram. +- Added support for filtering in change history. +- Added support for saving and annotating files in multiple languages. +- Added support for importing term translations from an MS Excel file. + #### Version 3.3.0 - Added the possibility to download annotated file without unconfirmed occurrences. diff --git a/package-lock.json b/package-lock.json index cd5f7b1a..960eb2e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termit-ui", - "version": "3.3.1", + "version": "3.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "termit-ui", - "version": "3.3.1", + "version": "3.4.0", "license": "GPL-3.0-only", "dependencies": { "@formatjs/intl-pluralrules": "^5.3.1", @@ -73,7 +73,7 @@ "@redux-devtools/extension": "^3.2.5", "@types/enzyme": "^3.10.13", "@types/jest": "^27.4.1", - "@types/js-cookie": "^3.0.3", + "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.10", "@types/luxon": "^3.4.2", "@types/node": "^18.11.17", @@ -3764,9 +3764,9 @@ } }, "node_modules/@types/js-cookie": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz", - "integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", "dev": true }, "node_modules/@types/json-schema": { @@ -20661,9 +20661,9 @@ } }, "@types/js-cookie": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz", - "integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", "dev": true }, "@types/json-schema": { diff --git a/package.json b/package.json index 58e4714a..b4abb7cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "termit-ui", - "version": "3.3.1", + "version": "3.4.0", "private": true, "homepage": ".", "license": "GPL-3.0-only", @@ -83,7 +83,7 @@ "@redux-devtools/extension": "^3.2.5", "@types/enzyme": "^3.10.13", "@types/jest": "^27.4.1", - "@types/js-cookie": "^3.0.3", + "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.10", "@types/luxon": "^3.4.2", "@types/node": "^18.11.17", diff --git a/src/action/AsyncActions.ts b/src/action/AsyncActions.ts index 6db20ef6..03592ff0 100644 --- a/src/action/AsyncActions.ts +++ b/src/action/AsyncActions.ts @@ -28,7 +28,7 @@ import * as jsonld from "jsonld"; import Message from "../model/Message"; import MessageType from "../model/MessageType"; import Term, { CONTEXT as TERM_CONTEXT, TermData } from "../model/Term"; -import VocabularyUtils, { IRI } from "../util/VocabularyUtils"; +import VocabularyUtils, { IRI, IRIImpl } from "../util/VocabularyUtils"; import ActionType, { PendingAsyncAction } from "./ActionType"; import Resource, { ResourceData } from "../model/Resource"; import RdfsResource, { @@ -61,6 +61,10 @@ 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 @@ -146,6 +150,7 @@ export function createVocabulary(vocabulary: Vocabulary) { export function loadVocabulary(iri: IRI, timestamp?: string) { const action = { type: ActionType.LOAD_VOCABULARY, + iri: IRIImpl.toString(iri), }; return (dispatch: ThunkDispatch, getState: () => TermItState) => { if (isActionRequestPending(getState(), action)) { @@ -1130,13 +1135,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/AsyncImportActions.ts b/src/action/AsyncImportActions.ts index 49f34831..a41d28b4 100644 --- a/src/action/AsyncImportActions.ts +++ b/src/action/AsyncImportActions.ts @@ -16,11 +16,18 @@ import { Action } from "redux"; import { loadVocabulary } from "./AsyncActions"; import Utils from "../util/Utils"; -export function importIntoExistingVocabulary(vocabularyIri: IRI, data: File) { +export function importIntoExistingVocabulary( + vocabularyIri: IRI, + data: File, + translationsOnly: boolean = false +) { const action = { type: ActionType.IMPORT_VOCABULARY }; const formData = new FormData(); formData.append("file", data, "thesaurus"); formData.append("namespace", vocabularyIri.namespace!); + if (translationsOnly) { + formData.append("translationsOnly", true.toString()); + } return (dispatch: ThunkDispatch) => { dispatch(asyncActionRequest(action, true)); return Ajax.post( @@ -82,13 +89,16 @@ const processError = ); }; -export function downloadExcelTemplate() { +export function downloadExcelTemplate(translationsOnly: boolean = false) { return (dispatch: ThunkDispatch) => { const action = { type: ActionType.LOAD_EXCEL_TEMPLATE }; dispatch(asyncActionRequest(action, true)); return Ajax.getRaw( `${Constants.API_PREFIX}/vocabularies/import/template`, - responseType("arraybuffer") + responseType("arraybuffer").param( + "translationsOnly", + translationsOnly.toString() + ) ) .then((response) => { Utils.fileDownload( diff --git a/src/action/AsyncVocabularyActions.ts b/src/action/AsyncVocabularyActions.ts index 48e766d0..a827996a 100644 --- a/src/action/AsyncVocabularyActions.ts +++ b/src/action/AsyncVocabularyActions.ts @@ -31,6 +31,11 @@ import ChangeRecord, { CONTEXT as CHANGE_RECORD_CONTEXT, } from "../model/changetracking/ChangeRecord"; import AssetFactory from "../util/AssetFactory"; +import { + getChangeTypeUri, + VocabularyContentChangeFilterData, +} from "../model/filter/VocabularyContentChangeFilterData"; +import { getLocalized } from "../model/MultilingualString"; export function loadTermCount(vocabularyIri: IRI) { const action = { type: ActionType.LOAD_TERM_COUNT, vocabularyIri }; @@ -137,6 +142,7 @@ export function loadVocabularyContentChanges(vocabularyIri: IRI) { export function loadVocabularyContentDetailedChanges( vocabularyIri: IRI, + filterData: VocabularyContentChangeFilterData, pageReq: PageRequest ) { const action = { @@ -145,11 +151,16 @@ export function loadVocabularyContentDetailedChanges( return (dispatch: ThunkDispatch) => { dispatch(asyncActionRequest(action, true)); + let params = param("namespace", vocabularyIri.namespace) + .param("page", pageReq.page?.toString()) + .param("size", pageReq.size?.toString()); + for (const [key, value] of Object.entries(filterData)) { + params = params.param(key, value); + } + params = params.param("type", getChangeTypeUri(filterData)); return Ajax.get( `${Constants.API_PREFIX}/vocabularies/${vocabularyIri.fragment}/history-of-content/detail`, - param("namespace", vocabularyIri.namespace) - .param("page", pageReq.page?.toString()) - .param("size", pageReq.size?.toString()) + params ) .then((data) => JsonLdUtils.compactAndResolveReferencesAsArray( @@ -157,6 +168,19 @@ export function loadVocabularyContentDetailedChanges( CHANGE_RECORD_CONTEXT ) ) + .then((data: ChangeRecord[]) => { + // adding labels to the label cache as they cannot be fetched from server + const labels: { [key: string]: string } = {}; + data.forEach((r) => { + if (r["label"]) { + labels[r.changedEntity.iri] = getLocalized(r["label"]); + } + }); + dispatch( + asyncActionSuccessWithPayload({ type: ActionType.GET_LABEL }, labels) + ); + return data; + }) .then((data: ChangeRecord[]) => { dispatch(asyncActionSuccess(action)); return data.map((r) => AssetFactory.createChangeRecord(r)); diff --git a/src/component/annotator/Annotation.tsx b/src/component/annotator/Annotation.tsx index f7fd899c..98fbaedb 100644 --- a/src/component/annotator/Annotation.tsx +++ b/src/component/annotator/Annotation.tsx @@ -39,6 +39,7 @@ interface AnnotationProps extends AnnotationSpanProps { accessLevel: AccessLevel; highlight?: boolean; filter: AnnotatorLegendFilter; + language?: string; } interface AnnotationState { @@ -316,6 +317,7 @@ export class Annotation extends React.Component< onToggleDetailOpen={this.toggleOpenDetail} onClose={this.onCloseDetail} accessLevel={this.props.accessLevel} + language={this.props.language} /> ); } diff --git a/src/component/annotator/Annotator.tsx b/src/component/annotator/Annotator.tsx index d4cfa446..44942877 100644 --- a/src/component/annotator/Annotator.tsx +++ b/src/component/annotator/Annotator.tsx @@ -66,6 +66,7 @@ interface AnnotatorProps extends HasI18n { user: User; file: File; vocabulary: Vocabulary; + annotationLanguage?: string; onUpdate: (newHtml: string) => void; setAnnotatorLegendFilter: ( @@ -625,7 +626,7 @@ export class Annotator extends React.Component { 0, + "annotator-header-scrolled": window.scrollY > 0, })} actions={[ { onRemove={this.onRemove} onResetSticky={this.resetStickyAnnotationId} highlightedTerm={this.state.highlightedTerm} + annotationLanguage={ + this.props.annotationLanguage || this.props.file.language + } /> diff --git a/src/component/annotator/AnnotatorContent.tsx b/src/component/annotator/AnnotatorContent.tsx index f54a6bbc..77b5e233 100644 --- a/src/component/annotator/AnnotatorContent.tsx +++ b/src/component/annotator/AnnotatorContent.tsx @@ -22,6 +22,7 @@ interface AnnotatorContentProps { content: DomHandlerNode[]; accessLevel: AccessLevel; // The level of access rights the current user has highlightedTerm: TermData | null; + annotationLanguage?: string; onRemove: (annotationId: string | string[]) => void; onUpdate: (annotationSpan: AnnotationSpanProps, term: Term | null) => void; @@ -64,6 +65,7 @@ const AnnotatorContent: React.FC = (props) => { onCreateTerm, accessLevel, highlightedTerm, + annotationLanguage, } = props; // Using memoization to skip processing and re-rendering of the content DOM in case it hasn't changed @@ -112,6 +114,7 @@ const AnnotatorContent: React.FC = (props) => { highlightedTerm !== null && elem.attribs.resource === highlightedTerm.iri } + language={annotationLanguage} {...attribs} > {children} @@ -142,6 +145,7 @@ const AnnotatorContent: React.FC = (props) => { onCreateTerm, accessLevel, highlightedTerm, + annotationLanguage, ]); return ( diff --git a/src/component/annotator/TermDefinitionAnnotation.tsx b/src/component/annotator/TermDefinitionAnnotation.tsx index 4a175f20..16077901 100644 --- a/src/component/annotator/TermDefinitionAnnotation.tsx +++ b/src/component/annotator/TermDefinitionAnnotation.tsx @@ -20,6 +20,7 @@ interface TermDefinitionAnnotationProps { text: string; isOpen: boolean; accessLevel: AccessLevel; + language?: string; onRemove: () => void; onSelectTerm: (term: Term | null) => void; @@ -118,6 +119,7 @@ export const TermDefinitionAnnotation: React.FC< term={term} resource={props.resource} textContent={props.text} + language={props.language} /> ); diff --git a/src/component/annotator/TermDefinitionAnnotationView.tsx b/src/component/annotator/TermDefinitionAnnotationView.tsx index 67347f74..fd89063f 100644 --- a/src/component/annotator/TermDefinitionAnnotationView.tsx +++ b/src/component/annotator/TermDefinitionAnnotationView.tsx @@ -7,6 +7,7 @@ interface TermDefinitionAnnotationViewProps { term?: Term | null; resource?: string; textContent: string; + language?: string; } const TermDefinitionAnnotationView: React.FC< @@ -20,7 +21,7 @@ const TermDefinitionAnnotationView: React.FC< {i18n("annotation.definition.term")} - + diff --git a/src/component/annotator/TermOccurrenceAnnotation.tsx b/src/component/annotator/TermOccurrenceAnnotation.tsx index e95594d2..73c9e989 100644 --- a/src/component/annotator/TermOccurrenceAnnotation.tsx +++ b/src/component/annotator/TermOccurrenceAnnotation.tsx @@ -22,6 +22,7 @@ interface TermOccurrenceAnnotationProps { annotationOrigin: string; isOpen: boolean; accessLevel: AccessLevel; + language?: string; onRemove: () => void; onSelectTerm: (term: Term | null) => void; @@ -129,6 +130,7 @@ export const TermOccurrenceAnnotation: React.FC< score={props.score} resource={props.resource} annotationClass={props.annotationClass} + language={props.language} /> ); diff --git a/src/component/annotator/TermOccurrenceAnnotationView.tsx b/src/component/annotator/TermOccurrenceAnnotationView.tsx index 235c31d2..ace47c93 100644 --- a/src/component/annotator/TermOccurrenceAnnotationView.tsx +++ b/src/component/annotator/TermOccurrenceAnnotationView.tsx @@ -9,6 +9,7 @@ interface TermOccurrenceAnnotationViewProps { score?: string; resource?: string; annotationClass: string; + language?: string; } const TermOccurrenceAnnotationView: React.FC< @@ -25,7 +26,7 @@ const TermOccurrenceAnnotationView: React.FC< {i18n("annotation.term.assigned-occurrence.termLabel")} - + diff --git a/src/component/annotator/__tests__/Annotator.test.tsx b/src/component/annotator/__tests__/Annotator.test.tsx index 99226224..402f736c 100644 --- a/src/component/annotator/__tests__/Annotator.test.tsx +++ b/src/component/annotator/__tests__/Annotator.test.tsx @@ -190,6 +190,39 @@ describe("Annotator", () => { ); }); + it("passes file language to content rendering", () => { + file.language = "en"; + const wrapper = shallow( + + ); + const contentRenderer = wrapper.find(AnnotatorContent); + expect(contentRenderer.props().annotationLanguage).toEqual(file.language); + }); + + it("passes provided annotation language to content rendering", () => { + file.language = "en"; + const wrapper = shallow( + + ); + const contentRenderer = wrapper.find(AnnotatorContent); + expect(contentRenderer.props().annotationLanguage).toEqual("cs"); + }); + describe("on mount", () => { const selector: TextQuoteSelector = { exactMatch: "test-term", diff --git a/src/component/changetracking/AssetHistory.tsx b/src/component/changetracking/AssetHistory.tsx index 6c3b3968..03eb10c9 100644 --- a/src/component/changetracking/AssetHistory.tsx +++ b/src/component/changetracking/AssetHistory.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useRef, 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,29 @@ 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 = useRef( + debounce( + ( + asset: Asset, + filterData: VocabularyContentChangeFilterData, + cb: (records?: ChangeRecord[]) => void + ) => dispatch(loadHistoryAction(asset, filterData)).then(cb), + Constants.INPUT_DEBOUNCE_WAIT_TIME + ) + ); + React.useEffect(() => { if (asset.iri !== Constants.EMPTY_ASSET_IRI) { + const filter = { + author: filterAuthor, + term: "", + changeType: filterType, + attribute: filterAttribute, + }; + //Check if vocabulary/term is a snapshot if ( (asset instanceof Term || asset instanceof Vocabulary) && @@ -35,53 +63,100 @@ 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.current( + 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.current(asset, filter, (recs) => { + if (recs) { + setRecords(recs); + } }); } } - }, [asset, dispatch]); + }, [ + asset, + dispatch, + filterAuthor, + filterType, + filterAttribute, + loadHistoryActionDebounced, + ]); 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 new file mode 100644 index 00000000..9a77aa02 --- /dev/null +++ b/src/component/changetracking/VocabularyContentDeleteRow.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { FormattedDate, FormattedTime } from "react-intl"; +import { Badge } from "reactstrap"; +import { useI18n } from "../hook/useI18n"; +import TermIriLink from "../term/TermIriLink"; +import { DeleteRowProps } from "./DeleteRow"; + +export const VocabularyContentDeleteRow: 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 VocabularyContentDeleteRow; diff --git a/src/component/changetracking/VocabularyContentPersistRow.tsx b/src/component/changetracking/VocabularyContentPersistRow.tsx index 6009cac5..343a67c7 100644 --- a/src/component/changetracking/VocabularyContentPersistRow.tsx +++ b/src/component/changetracking/VocabularyContentPersistRow.tsx @@ -22,7 +22,7 @@ export const VocabularyContentPersistRow: React.FC = ( - + {i18n(record.typeLabel)} diff --git a/src/component/changetracking/VocabularyContentUpdateRow.tsx b/src/component/changetracking/VocabularyContentUpdateRow.tsx index 85890e71..73af65d7 100644 --- a/src/component/changetracking/VocabularyContentUpdateRow.tsx +++ b/src/component/changetracking/VocabularyContentUpdateRow.tsx @@ -21,7 +21,7 @@ export const VocabularyContentUpdateRow: React.FC = (props) => { - + {i18n(record.typeLabel)} diff --git a/src/component/changetracking/__tests__/AssetHistory.test.tsx b/src/component/changetracking/__tests__/AssetHistory.test.tsx index b0f31fdb..cbd8ffbd 100644 --- a/src/component/changetracking/__tests__/AssetHistory.test.tsx +++ b/src/component/changetracking/__tests__/AssetHistory.test.tsx @@ -26,6 +26,7 @@ describe("AssetHistory", () => { mockDispatch = jest.fn(); jest.spyOn(Redux, "useDispatch").mockReturnValue(mockDispatch); jest.spyOn(AsyncActions, "loadHistory"); + jest.useFakeTimers(); }); it("loads asset history on mount", async () => { @@ -34,8 +35,12 @@ describe("AssetHistory", () => { mountWithIntl(); await act(async () => { await flushPromises(); + jest.runAllTimers(); }); - expect(AsyncActions.loadHistory).toHaveBeenCalledWith(asset); + expect(AsyncActions.loadHistory).toHaveBeenCalledWith( + asset, + expect.anything() + ); }); it("renders table with history records when they are available", async () => { @@ -54,21 +59,9 @@ describe("AssetHistory", () => { ); await act(async () => { await flushPromises(); + jest.runAllTimers(); }); wrapper.update(); expect(wrapper.exists(Table)).toBeTruthy(); }); - - it("shows notice about empty history when no records are found", async () => { - (mockDispatch as jest.Mock).mockResolvedValue([]); - const asset = Generator.generateTerm(); - const wrapper = mountWithIntl( - - ); - await act(async () => { - await flushPromises(); - }); - wrapper.update(); - expect(wrapper.exists("#history-empty-notice")).toBeTruthy(); - }); }); diff --git a/src/component/file/FileContentDetail.tsx b/src/component/file/FileContentDetail.tsx index faa83c4b..4393e567 100644 --- a/src/component/file/FileContentDetail.tsx +++ b/src/component/file/FileContentDetail.tsx @@ -27,6 +27,7 @@ interface FileDetailProvidedProps { iri: IRI; vocabularyIri: IRI; scrollTo?: TextQuoteSelector; // Selector of an annotation to scroll to (and highlight) after rendering + annotationLanguage?: string; } interface FileDetailOwnProps extends HasI18n { @@ -129,6 +130,7 @@ export class FileContentDetail extends React.Component< initialHtml={this.props.fileContent} scrollTo={this.props.scrollTo} onUpdate={this.onUpdate} + annotationLanguage={this.props.annotationLanguage} /> ); diff --git a/src/component/misc/AssetLabel.tsx b/src/component/misc/AssetLabel.tsx index 049d757d..a6d20d00 100644 --- a/src/component/misc/AssetLabel.tsx +++ b/src/component/misc/AssetLabel.tsx @@ -4,6 +4,7 @@ import { ThunkDispatch } from "../../util/Types"; import { getLabel } from "../../action/AsyncActions"; import Namespaces from "../../util/Namespaces"; import TermItState from "../../model/TermItState"; +import Utils from "../../util/Utils"; interface AssetLabelProps { iri: string; @@ -70,17 +71,10 @@ export class AssetLabel extends React.Component< } private shrinkFullIri(iri: string): string { - if (!this.props.shrinkFullIri || iri.indexOf("://") === -1) { - return iri; // It is prefixed + if (!this.props.shrinkFullIri) { + return iri; } - const lastSlashIndex = iri.lastIndexOf("/"); - const lastHashIndex = iri.lastIndexOf("#"); - return ( - "..." + - iri.substring( - lastHashIndex > lastSlashIndex ? lastHashIndex : lastSlashIndex - ) - ); + return Utils.shrinkFullIri(iri); } } diff --git a/src/component/multilingual/EditLanguageSelector.tsx b/src/component/multilingual/EditLanguageSelector.tsx index fd743a9e..dcc7d3ba 100644 --- a/src/component/multilingual/EditLanguageSelector.tsx +++ b/src/component/multilingual/EditLanguageSelector.tsx @@ -1,10 +1,8 @@ import * as React from "react"; -import ISO6391 from "iso-639-1"; import classNames from "classnames"; // @ts-ignore import { IntelligentTreeSelect } from "intelligent-tree-select"; -import Constants from "../../util/Constants"; -import { getShortLocale } from "../../util/IntlUtil"; +import { getLanguageOptions, Language } from "../../util/IntlUtil"; import { renderLanguages } from "./LanguageSelector"; import { Nav, NavItem, NavLink } from "reactstrap"; import { FaPlusCircle } from "react-icons/fa"; @@ -18,29 +16,6 @@ interface EditLanguageSelectorProps { onRemove: (lang: string) => void; } -interface Language { - code: string; - name: string; - nativeName: string; -} - -function prioritizeLanguages(options: Language[], languages: string[]) { - languages.forEach((lang) => { - const ind = options.findIndex((v) => v.code === lang); - const option = options[ind]; - options.splice(ind, 1); - options.unshift(option); - }); - return options; -} - -const OPTIONS = prioritizeLanguages( - ISO6391.getLanguages(ISO6391.getAllCodes()), - Object.getOwnPropertyNames(Constants.LANG).map((lang) => - getShortLocale(Constants.LANG[lang].locale) - ) -); - const EditLanguageSelector: React.FC = (props) => { const { language, existingLanguages, onSelect, onRemove } = props; const { i18n, formatMessage } = useI18n(); @@ -51,7 +26,7 @@ const EditLanguageSelector: React.FC = (props) => { if (existingLanguages.indexOf(language) === -1) { existingLanguages.push(language); } - const options = OPTIONS.slice(); + const options = getLanguageOptions().slice(); for (const existing of existingLanguages) { const toRemove = options.findIndex((o) => o.code === existing); options.splice(toRemove, 1); diff --git a/src/component/resource/ResourceFileDetail.tsx b/src/component/resource/ResourceFileDetail.tsx index 249729ee..3b0c1eb3 100644 --- a/src/component/resource/ResourceFileDetail.tsx +++ b/src/component/resource/ResourceFileDetail.tsx @@ -44,6 +44,7 @@ type ResourceFileDetailProps = StoreStateProps & interface ResourceFileDetailState { vocabularyIri?: IRI | null; scrollToSelector?: TextQuoteSelector; + annotationLanguage?: string; } export class ResourceFileDetail extends React.Component< @@ -139,6 +140,7 @@ export class ResourceFileDetail extends React.Component< if (res) { this.setState({ vocabularyIri: VocabularyUtils.create(res.vocabularies[0].iri!), + annotationLanguage: res.language, }); } else { this.setState({ vocabularyIri: null }); diff --git a/src/component/resource/document/Files.tsx b/src/component/resource/document/Files.tsx index f2a43430..11d523f4 100644 --- a/src/component/resource/document/Files.tsx +++ b/src/component/resource/document/Files.tsx @@ -1,7 +1,7 @@ import TermItFile from "../../../model/File"; import File from "../../../model/File"; import Utils from "../../../util/Utils"; -import { ButtonToolbar, Label, Table } from "reactstrap"; +import { Badge, ButtonToolbar, Label, Table } from "reactstrap"; import { useI18n } from "../../hook/useI18n"; interface FilesProps { @@ -20,7 +20,7 @@ const Files = (props: FilesProps) => { -
+
{files.length > 0 ? ( - +
{files.map((v: File) => ( - + - - - - - - - - - - - - - ); - } -} + return ( + + + + + setLabel(e.target.value)} + hint={i18n("required")} + /> + + + + + + + + + + + + + + + + + + + + ); +}; -export default injectIntl(withI18n(CreateFileMetadata)); +export default CreateFileMetadata; diff --git a/src/component/resource/file/LanguageSelector.tsx b/src/component/resource/file/LanguageSelector.tsx new file mode 100644 index 00000000..ea46c853 --- /dev/null +++ b/src/component/resource/file/LanguageSelector.tsx @@ -0,0 +1,33 @@ +import React from "react"; +// @ts-ignore +import { IntelligentTreeSelect } from "intelligent-tree-select"; +import { getLanguageOptions, Language } from "../../../util/IntlUtil"; +import { useI18n } from "../../hook/useI18n"; + +const LanguageSelector: React.FC<{ + onChange: (lang: string) => void; + value: string; +}> = ({ onChange, value }) => { + const options = getLanguageOptions(); + const { i18n } = useI18n(); + return ( + onChange(item.code)} + options={options} + maxHeight={200} + multi={false} + labelKey="nativeName" + valueKey="code" + classNamePrefix="react-select" + simpleTreeData={true} + renderAsTree={false} + showSettings={false} + isClearable={false} + placeholder="" + noResultsText={i18n("search.no-results")} + value={options.find((o) => o.code === value)} + /> + ); +}; + +export default LanguageSelector; diff --git a/src/component/resource/file/__tests__/CreateFileMetadata.test.tsx b/src/component/resource/file/__tests__/CreateFileMetadata.test.tsx index 6fa3e0f8..866062b4 100644 --- a/src/component/resource/file/__tests__/CreateFileMetadata.test.tsx +++ b/src/component/resource/file/__tests__/CreateFileMetadata.test.tsx @@ -1,11 +1,10 @@ import Resource from "../../../../model/Resource"; import Ajax from "../../../../util/Ajax"; -import { - flushPromises, - mountWithIntl, -} from "../../../../__tests__/environment/Environment"; -import { CreateFileMetadata } from "../CreateFileMetadata"; +import { mountWithIntl } from "../../../../__tests__/environment/Environment"; +import CreateFileMetadata from "../CreateFileMetadata"; import { intlFunctions } from "../../../../__tests__/environment/IntlUtil"; +import UploadFile from "../UploadFile"; +import { act } from "react-dom/test-utils"; jest.mock("../../../../util/Ajax", () => { const originalModule = jest.requireActual("../../../../util/Ajax"); @@ -43,9 +42,12 @@ describe("CreateFileMetadata", () => { {...intlFunctions()} /> ); - (wrapper.find(CreateFileMetadata).instance() as CreateFileMetadata).setFile( - file as File - ); + act(() => { + wrapper + .find(UploadFile) + .props() + .setFile(file as File); + }); const labelInput = wrapper.find('input[name="create-resource-label"]'); expect((labelInput.getDOMNode() as HTMLInputElement).value).toEqual( fileName diff --git a/src/component/term/RelatedTermsList.tsx b/src/component/term/RelatedTermsList.tsx index 422459d2..0b46ef0f 100644 --- a/src/component/term/RelatedTermsList.tsx +++ b/src/component/term/RelatedTermsList.tsx @@ -12,6 +12,7 @@ import { ThunkDispatch } from "../../util/Types"; import VocabularyNameBadgeButton from "../vocabulary/VocabularyNameBadgeButton"; import TermItState from "../../model/TermItState"; import { createTermNonTerminalStateMatcher } from "./TermTreeSelectHelper"; +import { getShortLocale } from "../../util/IntlUtil"; interface RelatedTermsListProps { term: Term; @@ -33,8 +34,11 @@ const RelatedTermsList: React.FC = (props) => { }, [dispatch, term.iri, term.vocabulary]); const terms = React.useMemo( () => - Term.consolidateRelatedAndRelatedMatch(term).filter(terminalStateFilter), - [term, terminalStateFilter] + Term.consolidateRelatedAndRelatedMatch( + term, + getShortLocale(language) + ).filter(terminalStateFilter), + [language, term, terminalStateFilter] ); return ( diff --git a/src/component/term/TermIriLink.tsx b/src/component/term/TermIriLink.tsx index 6b337538..18e72a38 100644 --- a/src/component/term/TermIriLink.tsx +++ b/src/component/term/TermIriLink.tsx @@ -4,31 +4,46 @@ import VocabularyUtils from "../../util/VocabularyUtils"; import Term from "../../model/Term"; import { useDispatch } from "react-redux"; import { ThunkDispatch } from "../../util/Types"; -import { loadTermByIri } from "../../action/AsyncActions"; +import { getLabel, loadTermByIri } from "../../action/AsyncActions"; import TermLink from "./TermLink"; import OutgoingLink from "../misc/OutgoingLink"; +import Utils from "../../util/Utils"; interface TermIriLinkProps { iri: string; id?: string; activeTab?: string; + shrinkFullIri?: boolean; } const TermIriLink: React.FC = (props) => { const { iri, id, activeTab } = props; const [term, setTerm] = useState(null); const dispatch: ThunkDispatch = useDispatch(); + const [label, setLabel] = useState(); useEffect(() => { const tIri = VocabularyUtils.create(iri); dispatch(loadTermByIri(tIri)).then((term) => setTerm(term)); }, [iri, dispatch, setTerm]); + // if term is null, try to acquire the label from cache + useEffect(() => { + if (term === null) { + dispatch(getLabel(iri)).then((label) => setLabel(label)); + } + }, [term, iri, dispatch]); + return ( <> {term !== null ? ( ) : ( - + )} ); diff --git a/src/component/term/TermList.tsx b/src/component/term/TermList.tsx index 0b51343e..2975c39c 100644 --- a/src/component/term/TermList.tsx +++ b/src/component/term/TermList.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import Term, { termComparator, TermInfo } from "../../model/Term"; +import Term, { createTermComparator, TermInfo } from "../../model/Term"; // @ts-ignore import { Col, Label, List, Row } from "reactstrap"; import TermLink from "./TermLink"; @@ -16,7 +16,9 @@ interface TermListProps { vocabularyIri?: string; language: string; - addonBeforeRenderer?: (t: Term | TermInfo) => JSX.Element | undefined | null; // An element to render before the term link + addonBeforeRenderer?: ( + t: Term | TermInfo + ) => React.JSX.Element | undefined | null; // An element to render before the term link } const TermList: React.FC = (props) => { @@ -27,7 +29,7 @@ const TermList: React.FC = (props) => { ); const terminalStateFilter = createTermNonTerminalStateMatcher(terminalStates); const toRender = Utils.sanitizeArray(terms).filter(terminalStateFilter); - toRender.sort(termComparator); + toRender.sort(createTermComparator(language)); return ( diff --git a/src/component/term/TermMetadataEdit.tsx b/src/component/term/TermMetadataEdit.tsx index 0d203227..46f0cb1a 100644 --- a/src/component/term/TermMetadataEdit.tsx +++ b/src/component/term/TermMetadataEdit.tsx @@ -387,7 +387,8 @@ export class TermMetadataEdit extends React.Component< term={this.props.term} vocabularyIri={this.props.term.vocabulary?.iri!} selected={Term.consolidateRelatedAndRelatedMatch( - this.state + this.state, + this.props.language )} onChange={this.onRelatedChange} language={language} diff --git a/src/component/vocabulary/TermChangeFrequency.tsx b/src/component/vocabulary/TermChangeFrequency.tsx index 3732dad5..168a5042 100644 --- a/src/component/vocabulary/TermChangeFrequency.tsx +++ b/src/component/vocabulary/TermChangeFrequency.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useState } from "react"; import Vocabulary from "../../model/Vocabulary"; import { ThunkDispatch } from "../../util/Types"; import { useDispatch } from "react-redux"; @@ -14,6 +15,7 @@ import { loadVocabularyContentDetailedChanges, } from "../../action/AsyncVocabularyActions"; import ChangeRecord from "../../model/changetracking/ChangeRecord"; +import { VocabularyContentChangeFilterData } from "../../model/filter/VocabularyContentChangeFilterData"; interface TermChangeFrequencyProps { vocabulary: Vocabulary; @@ -30,6 +32,13 @@ const TermChangeFrequency: React.FC = (props) => { const { i18n } = useI18n(); const dispatch: ThunkDispatch = useDispatch(); const [page, setPage] = React.useState(0); + const [filterData, setFilterData] = + useState({ + term: "", + changeType: "", + attribute: "", + author: "", + }); React.useEffect(() => { if (vocabulary.iri !== Constants.EMPTY_ASSET_IRI) { trackPromise( @@ -47,13 +56,14 @@ const TermChangeFrequency: React.FC = (props) => { dispatch( loadVocabularyContentDetailedChanges( VocabularyUtils.create(vocabulary.iri), + filterData, { page: page, size: Constants.VOCABULARY_CONTENT_HISTORY_LIMIT } ) ).then((changeRecords) => setChangeRecords(changeRecords)), "term-change-frequency" ); } - }, [vocabulary.iri, dispatch, page]); + }, [vocabulary.iri, dispatch, page, filterData]); return ( <> @@ -67,7 +77,7 @@ const TermChangeFrequency: React.FC = (props) => { page={page} setPage={setPage} pageSize={Constants.VOCABULARY_CONTENT_HISTORY_LIMIT} - itemCount={changeRecords?.length ?? 0} + applyFilter={setFilterData} /> ); diff --git a/src/component/vocabulary/TermChangeFrequencyUI.scss b/src/component/vocabulary/TermChangeFrequencyUI.scss new file mode 100644 index 00000000..8c71cc6e --- /dev/null +++ b/src/component/vocabulary/TermChangeFrequencyUI.scss @@ -0,0 +1,9 @@ +.cursor-pointer { + cursor: pointer; +} +.color-primary { + color: var(--primary); +} +#date-filter-col { + padding-left: 30px; +} diff --git a/src/component/vocabulary/TermChangeFrequencyUI.tsx b/src/component/vocabulary/TermChangeFrequencyUI.tsx index 6235d1f3..ac3284fb 100644 --- a/src/component/vocabulary/TermChangeFrequencyUI.tsx +++ b/src/component/vocabulary/TermChangeFrequencyUI.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useEffect, useRef, useState } from "react"; import Chart from "react-apexcharts"; import { Col, Row, Table } from "reactstrap"; import { useI18n } from "../hook/useI18n"; @@ -8,8 +9,16 @@ import ChangeRecord from "../../model/changetracking/ChangeRecord"; import { UpdateRecord } from "../../model/changetracking/UpdateRecord"; import VocabularyContentPersistRow from "../changetracking/VocabularyContentPersistRow"; import VocabularyContentUpdateRow from "../changetracking/VocabularyContentUpdateRow"; -import If from "../misc/If"; import SimplePagination from "../dashboard/widget/lastcommented/SimplePagination"; +import CustomInput from "../misc/CustomInput"; +import Select from "../misc/Select"; +import "./TermChangeFrequencyUI.scss"; +import PersistRecord from "../../model/changetracking/PersistRecord"; +import DeleteRecord from "../../model/changetracking/DeleteRecord"; +import VocabularyContentDeleteRow from "../changetracking/VocabularyContentDeleteRow"; +import { debounce } from "lodash"; +import Constants from "../../util/Constants"; +import { VocabularyContentChangeFilterData } from "../../model/filter/VocabularyContentChangeFilterData"; interface TermChangeFrequencyUIProps { aggregatedRecords: AggregatedChangeInfo[] | null; @@ -17,7 +26,7 @@ interface TermChangeFrequencyUIProps { page: number; setPage: (page: number) => void; pageSize: number; - itemCount: number; + applyFilter: (filterData: VocabularyContentChangeFilterData) => void; } /** @@ -64,10 +73,34 @@ const TermChangeFrequencyUI: React.FC = ({ page, setPage, pageSize, - itemCount, + applyFilter, }) => { const { i18n, locale } = useI18n(); - if (!aggregatedRecords || !changeRecords) { + + const [filterAuthor, setFilterAuthor] = useState(""); + const [filterTerm, setFilterTerm] = useState(""); + const [filterType, setFilterType] = useState(""); + const [filterAttribute, setFilterAttribute] = useState(""); + const applyFilterDebounced = useRef( + debounce(applyFilter, Constants.INPUT_DEBOUNCE_WAIT_TIME) + ); + + useEffect(() => { + applyFilterDebounced.current({ + author: filterAuthor, + term: filterTerm, + changeType: filterType, + attribute: filterAttribute, + }); + }, [ + filterAuthor, + filterTerm, + filterType, + filterAttribute, + applyFilterDebounced, + ]); + + if (!aggregatedRecords) { return
 
; } @@ -89,6 +122,9 @@ const TermChangeFrequencyUI: React.FC = ({ const termUpdates = aggregatedRecords.filter( (r) => r.types.indexOf(VocabularyUtils.UPDATE_EVENT) !== -1 ); + const termDeletions = aggregatedRecords.filter( + (r) => r.types.indexOf(VocabularyUtils.DELETE_EVENT) !== -1 + ); const options = { chart: { @@ -134,20 +170,28 @@ const TermChangeFrequencyUI: React.FC = ({ }; const series = [ + { + name: i18n("vocabulary.termchanges.creations"), + type: "column", + data: termCreations.map((a) => [a.getDate(), a.count]), + color: "var(--primary)", + }, { name: i18n("vocabulary.termchanges.updates"), type: "column", - data: termUpdates.map((a) => [a.getDate(), -1 * a.count]), + data: termUpdates.map((a) => [a.getDate(), a.count]), + color: "var(--info)", }, { - name: i18n("vocabulary.termchanges.creations"), + name: i18n("vocabulary.termchanges.deletions"), type: "column", - data: termCreations.map((a) => [a.getDate(), a.count]), + data: termDeletions.map((a) => [a.getDate(), a.count]), + color: "var(--danger)", }, ]; return ( -
+ @@ -157,29 +201,76 @@ const TermChangeFrequencyUI: React.FC = ({ - - + + + + + + + + - {changeRecords.map((r) => - r instanceof UpdateRecord ? ( - - ) : ( - - ) - )} + {changeRecords?.map((r) => { + if (r instanceof PersistRecord) { + return ; + } + if (r instanceof UpdateRecord) { + return ; + } + if (r instanceof DeleteRecord) { + return ; + } + return null; + })}
{v.label} + {v.language && ( + + {v.language} + + )} + {v.label} + {props.itemActions(v)} diff --git a/src/component/resource/file/CreateFileMetadata.tsx b/src/component/resource/file/CreateFileMetadata.tsx index cbefd0d1..daf7de26 100644 --- a/src/component/resource/file/CreateFileMetadata.tsx +++ b/src/component/resource/file/CreateFileMetadata.tsx @@ -1,101 +1,105 @@ -import * as React from "react"; -import { injectIntl } from "react-intl"; -import withI18n, { HasI18n } from "../../hoc/withI18n"; -import { Button, ButtonToolbar, Col, Form, Row } from "reactstrap"; +import React from "react"; +import { + Button, + ButtonToolbar, + Col, + Form, + FormGroup, + Label, + Row, +} from "reactstrap"; import UploadFile from "./UploadFile"; import TermItFile from "../../../model/File"; import CustomInput from "../../misc/CustomInput"; -import { AssetData } from "../../../model/Asset"; +import { useI18n } from "../../hook/useI18n"; +import { useSelector } from "react-redux"; +import TermItState from "../../../model/TermItState"; +import LanguageSelector from "./LanguageSelector"; -interface CreateFileMetadataProps extends HasI18n { +interface CreateFileMetadataProps { onCreate: (termItFile: TermItFile, file: File) => any; onCancel: () => void; } -interface CreateFileMetadataState extends AssetData { - iri: string; - label: string; - file?: File; - dragActive: boolean; -} - -export class CreateFileMetadata extends React.Component< - CreateFileMetadataProps, - CreateFileMetadataState -> { - constructor(props: CreateFileMetadataProps) { - super(props); - this.state = { - iri: "", - label: "", - file: undefined, - dragActive: false, - }; - } +const CreateFileMetadata: React.FC = ({ + onCreate, + onCancel, +}) => { + const { i18n } = useI18n(); + const [label, setLabel] = React.useState(""); + const [file, setFile] = React.useState(); + const lang = useSelector( + (state: TermItState) => state.configuration.language + ); + const [language, setLanguage] = React.useState(lang); - protected onLabelChange = (e: React.ChangeEvent): void => { - const label = e.currentTarget.value; - this.setState({ label }); + const onFileSelected = (file: File) => { + setFile(file); + setLabel(file.name); }; - - public onCreate = () => { - const { file, dragActive, ...data } = this.state; + const onSubmit = () => { if (file) { - this.props.onCreate(new TermItFile(data), file); + onCreate( + new TermItFile({ + iri: "", + label, + language, + }), + file + ); } }; - - public setFile = (file: File) => { - this.setState({ file, label: file.name, dragActive: false }); - }; - - public cannotSubmit = () => { - return !this.state.file || this.state.label.trim().length === 0; + const cannotSubmit = () => { + return !file || label.trim().length === 0; }; - public render() { - const i18n = this.props.i18n; - - return ( -
- - -
{i18n("history.whenwho")} {i18n("type.term")}{i18n("history.type")}{i18n("history.changedAttribute")}{i18n("history.type")}{i18n("history.changedAttribute")}
+ setFilterAuthor(e.target.value)} + /> + + setFilterTerm(e.target.value)} + /> + + + + setFilterAttribute(e.target.value)} + /> +
- 0}> - - + ); diff --git a/src/component/vocabulary/VocabularyActions.tsx b/src/component/vocabulary/VocabularyActions.tsx index e4916d80..ae933930 100644 --- a/src/component/vocabulary/VocabularyActions.tsx +++ b/src/component/vocabulary/VocabularyActions.tsx @@ -12,7 +12,7 @@ import { GoCloudUpload, GoRepoForked, } from "react-icons/go"; -import ImportBackupOfVocabulary from "./importing/ImportBackupOfVocabulary"; +import LoadVocabularyFromFile from "./importing/LoadVocabularyFromFile"; import { FaCamera } from "react-icons/fa"; import Vocabulary from "../../model/Vocabulary"; import IfVocabularyActionAuthorized from "./authorization/IfVocabularyActionAuthorized"; @@ -28,7 +28,7 @@ interface VocabularyActionsProps { vocabulary: Vocabulary; onAnalyze: () => void; onExport: () => void; - onImport: (file: File, rename: Boolean) => Promise; + onImport: (file: File, translationsOnly: boolean) => Promise; onCreateSnapshot: () => void; } @@ -46,7 +46,7 @@ const VocabularyActions: React.FC = ({ return ( <> - setShowImportDialog(false)} diff --git a/src/component/vocabulary/VocabularySummary.tsx b/src/component/vocabulary/VocabularySummary.tsx index 25134161..92fa2c16 100644 --- a/src/component/vocabulary/VocabularySummary.tsx +++ b/src/component/vocabulary/VocabularySummary.tsx @@ -71,14 +71,17 @@ interface VocabularySummaryProps ) => Promise; updateVocabulary: (vocabulary: Vocabulary) => Promise; removeVocabulary: (vocabulary: Vocabulary) => Promise; - importSkos: (iri: IRI, file: File) => Promise; + importSkos: ( + iri: IRI, + file: File, + translationsOnly?: boolean + ) => Promise; executeTextAnalysisOnAllTerms: (iri: IRI) => void; createSnapshot: (iri: IRI) => Promise; updateDocument: (document: Document) => Promise; } export interface VocabularySummaryState extends EditableComponentState { - selectDocumentDialogOpen: boolean; showExportDialog: boolean; showSnapshotDialog: boolean; language: string; @@ -105,7 +108,6 @@ export class VocabularySummary extends EditableComponent< showRemoveDialog: false, showExportDialog: false, showSnapshotDialog: false, - selectDocumentDialogOpen: false, language: resolveInitialLanguage( props.vocabulary, props.locale, @@ -167,6 +169,11 @@ export class VocabularySummary extends EditableComponent< this.props.requestVocabularyValidation(iriFromUrl, this.props.stompClient); }; + public reloadVocabulary = () => { + const iri = VocabularyUtils.create(this.props.vocabulary.iri); + this.props.loadVocabulary(iri); + }; + public setLanguage = (language: string) => { this.setState({ language }); }; @@ -210,16 +217,13 @@ export class VocabularySummary extends EditableComponent< this.setState({ showSnapshotDialog: !this.state.showSnapshotDialog }); }; - private onImport = (file: File) => + private onImport = (file: File, translationsOnly: boolean) => this.props.importSkos( VocabularyUtils.create(this.props.vocabulary.iri), - file + file, + translationsOnly ); - public onFileAdded = () => { - this.loadVocabulary(); - }; - private onExecuteTextAnalysisOnAllTerms = () => { this.props.executeTextAnalysisOnAllTerms( VocabularyUtils.create(this.props.vocabulary.iri) @@ -324,7 +328,7 @@ export class VocabularySummary extends EditableComponent< match={this.props.match} language={this.state.language} selectLanguage={this.setLanguage} - onChange={this.loadVocabulary} + onChange={this.reloadVocabulary} /> )} @@ -370,8 +374,8 @@ export default connect( dispatch(updateVocabulary(vocabulary)), removeVocabulary: (vocabulary: Vocabulary) => dispatch(removeVocabulary(vocabulary)), - importSkos: (iri: IRI, file: File) => - dispatch(importIntoExistingVocabulary(iri, file)), + importSkos: (iri: IRI, file: File, translationsOnly?: boolean) => + dispatch(importIntoExistingVocabulary(iri, file, translationsOnly)), executeTextAnalysisOnAllTerms: (iri: IRI) => dispatch(executeTextAnalysisOnAllTerms(iri)), createSnapshot: (iri: IRI) => dispatch(createVocabularySnapshot(iri)), diff --git a/src/component/vocabulary/__tests__/RemoveVocabularyDialog.test.tsx b/src/component/vocabulary/__tests__/RemoveVocabularyDialog.test.tsx index 90af98bb..328b6c23 100644 --- a/src/component/vocabulary/__tests__/RemoveVocabularyDialog.test.tsx +++ b/src/component/vocabulary/__tests__/RemoveVocabularyDialog.test.tsx @@ -31,7 +31,8 @@ jest.mock("../../../action/AsyncVocabularyActions", () => ({ jest.mock("../../../action/AsyncActions", () => ({ ...jest.requireActual("../../../action/AsyncActions"), - loadTermByIri: jest.fn().mockResolvedValue(null), + loadTermByIri: () => () => Promise.resolve(null), + getLabel: () => () => Promise.resolve(null), })); describe("RemoveVocabularyDialog", () => { @@ -54,9 +55,15 @@ describe("RemoveVocabularyDialog", () => { (getVocabularyRelations as jest.Mock).mockResolvedValue([]); (getVocabularyTermsRelations as jest.Mock).mockResolvedValue([]); - (useDispatch as jest.Mock).mockReturnValue( - (value: any) => value || Promise.resolve() - ); + (useDispatch as jest.Mock).mockReturnValue((arg: any) => { + if (arg instanceof Promise) { + return arg; + } + if (arg instanceof Function) { + return arg(); + } + return arg; + }); Ajax.get = jest.fn().mockResolvedValue(null); diff --git a/src/component/vocabulary/importing/ImportBackupOfVocabulary.tsx b/src/component/vocabulary/importing/ImportBackupOfVocabulary.tsx deleted file mode 100644 index 93cc67c9..00000000 --- a/src/component/vocabulary/importing/ImportBackupOfVocabulary.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; -import { Alert, Label, Modal, ModalBody, ModalHeader } from "reactstrap"; -import { useI18n } from "../../hook/useI18n"; -import PromiseTrackingMask from "../../misc/PromiseTrackingMask"; -import { trackPromise } from "react-promise-tracker"; -import { FormattedMessage } from "react-intl"; -import ImportVocabularyDialog from "./ImportVocabularyDialog"; -import { useDispatch, useSelector } from "react-redux"; -import { ThunkDispatch } from "../../../util/Types"; -import { downloadExcelTemplate } from "../../../action/AsyncImportActions"; -import TermItState from "../../../model/TermItState"; - -interface ImportVocabularyProps { - showDialog: boolean; - onImport: (file: File, rename: Boolean) => Promise; - closeDialog: () => void; -} - -export const ImportVocabulary: React.FC = ({ - showDialog, - closeDialog, - onImport, -}) => { - const { i18n } = useI18n(); - const dispatch: ThunkDispatch = useDispatch(); - const onSubmit = (file: File, rename: Boolean) => - trackPromise(onImport(file, rename), "vocabulary-import").then(closeDialog); - const downloadTemplate = () => { - dispatch(downloadExcelTemplate()); - }; - const vocabularyNotEmpty = - (useSelector((state: TermItState) => state.vocabulary.termCount) || 0) > 0; - - return ( - <> - - - {i18n("vocabulary.summary.import.dialog.title")} - - - - -
    -
  • - -
  • -
  • - ( - - {chunks} - - ), - }} - /> -
  • -
- {vocabularyNotEmpty && ( - - - - )} - -
-
- - ); -}; - -export default ImportVocabulary; diff --git a/src/component/vocabulary/importing/ImportTranslationsDialog.tsx b/src/component/vocabulary/importing/ImportTranslationsDialog.tsx new file mode 100644 index 00000000..d9efb1a8 --- /dev/null +++ b/src/component/vocabulary/importing/ImportTranslationsDialog.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import UploadFile from "../../resource/file/UploadFile"; +import { Alert, Button, ButtonToolbar, Col, Form, Row } from "reactstrap"; +import { useI18n } from "../../hook/useI18n"; +import { FormattedMessage } from "react-intl"; + +export const ImportTranslationsDialog: React.FC<{ + onSubmit: (file: File) => void; + onCancel: () => void; + onDownloadTemplate: () => void; +}> = ({ onSubmit, onCancel, onDownloadTemplate }) => { + const { i18n } = useI18n(); + const [file, setFile] = React.useState(); + const cannotSubmit = () => !file; + return ( + <> +
+
+ ( + + {chunks} + + ), + }} + /> +
+ + + + + + + + + + + + + + + ); +}; diff --git a/src/component/vocabulary/importing/ImportVocabularyDialog.tsx b/src/component/vocabulary/importing/ImportVocabularyDialog.tsx index 4a4bf019..d28d1a86 100644 --- a/src/component/vocabulary/importing/ImportVocabularyDialog.tsx +++ b/src/component/vocabulary/importing/ImportVocabularyDialog.tsx @@ -7,7 +7,7 @@ import "./ImportVocabularyDialog.scss"; interface ImportVocabularyDialogProps { propKeyPrefix: string; - onCreate: (file: File, rename: Boolean) => any; + onCreate: (file: File, rename: boolean) => any; onCancel: () => void; allowRename?: boolean; } @@ -15,7 +15,7 @@ interface ImportVocabularyDialogProps { const ImportVocabularyDialog = (props: ImportVocabularyDialogProps) => { const { i18n } = useI18n(); const [file, setFile] = useState(); - const [rename, setRename] = useState(false); + const [rename, setRename] = useState(false); const onCreate = () => { if (!file) { diff --git a/src/component/vocabulary/importing/LoadVocabularyFromFile.tsx b/src/component/vocabulary/importing/LoadVocabularyFromFile.tsx new file mode 100644 index 00000000..f0ee0728 --- /dev/null +++ b/src/component/vocabulary/importing/LoadVocabularyFromFile.tsx @@ -0,0 +1,119 @@ +import React, { useState } from "react"; +import { Alert, Modal, ModalBody, ModalHeader } from "reactstrap"; +import { useI18n } from "../../hook/useI18n"; +import PromiseTrackingMask from "../../misc/PromiseTrackingMask"; +import { trackPromise } from "react-promise-tracker"; +import { FormattedMessage } from "react-intl"; +import ImportVocabularyDialog from "./ImportVocabularyDialog"; +import { useDispatch, useSelector } from "react-redux"; +import { ThunkDispatch } from "../../../util/Types"; +import { downloadExcelTemplate } from "../../../action/AsyncImportActions"; +import TermItState from "../../../model/TermItState"; +import Tabs from "../../misc/Tabs"; +import { ImportTranslationsDialog } from "./ImportTranslationsDialog"; + +interface LoadVocabularyFromFileProps { + showDialog: boolean; + onImport: (file: File, translationsOnly: boolean) => Promise; + closeDialog: () => void; +} + +export const LoadVocabularyFromFile: React.FC = ({ + showDialog, + closeDialog, + onImport, +}) => { + const { i18n } = useI18n(); + const dispatch: ThunkDispatch = useDispatch(); + const [selectedTab, setSelectedTab] = useState( + "vocabulary.summary.import.dialog.tab.replaceContent" + ); + const onClose = () => { + closeDialog(); + setSelectedTab("vocabulary.summary.import.dialog.tab.replaceContent"); + }; + const onImportContent = (file: File) => + trackPromise(onImport(file, false), "vocabulary-import").then(onClose); + const onImportTranslations = (file: File) => { + trackPromise(onImport(file, true), "vocabulary-import").then(onClose); + }; + const downloadTemplate = () => { + dispatch(downloadExcelTemplate()); + }; + const downloadTranslationsTemplate = () => { + dispatch(downloadExcelTemplate(true)); + }; + const vocabularyNotEmpty = + (useSelector((state: TermItState) => state.vocabulary.termCount) || 0) > 0; + + return ( + <> + + + {i18n("vocabulary.summary.import.dialog.title")} + + + + +
+ +
+
    +
  • + +
  • +
  • + ( + + {chunks} + + ), + }} + /> +
  • +
+ {vocabularyNotEmpty && ( + + + + )} + + + ), + "vocabulary.summary.import.dialog.tab.translations": ( + + ), + }} + changeTab={setSelectedTab as (t: string) => void} + /> +
+
+ + ); +}; + +export default LoadVocabularyFromFile; diff --git a/src/i18n/cs.ts b/src/i18n/cs.ts index ebeb3399..a74b61f1 100644 --- a/src/i18n/cs.ts +++ b/src/i18n/cs.ts @@ -305,21 +305,27 @@ const cs = { "vocabulary.summary.export.rdfxml.title": "Export ve formátu RDF/XML.", "vocabulary.summary.export.error": "Nepodařilo se získat data z odpovědi serveru.", - "vocabulary.summary.import.action": "Obnovit ze zálohy", + "vocabulary.summary.import.action": "Nahrát ze souboru", "vocabulary.summary.import.action.tooltip": - "Obnovit slovník ze zálohy ve formátu SKOS či MS Excel", + "Nahrát obsah slovníku ze souboru obsahujícího data ve formátu SKOS či MS Excel", "vocabulary.summary.import.dialog.title": - "Obnova exportované verze slovníku", + "Import obsahu slovníku ze souboru", + "vocabulary.summary.import.dialog.tab.replaceContent": "Nahradit obsah", + "vocabulary.summary.import.dialog.tab.translations": "Importovat překlady", "vocabulary.summary.import.dialog.label": - "Nahrajte vyexportovanou verzi tohoto slovníku ", + "Nahrajte vyexportovanou verzi tohoto slovníku", "vocabulary.summary.import.dialog.skosImport": "Ve formátu SKOS a obsahující jediný skos:ConceptScheme s IRI ve tvaru '<'IRI-TOHOTO-SLOVNÍKU'>'/glosář.", "vocabulary.summary.import.dialog.excelImport": - "MS Excel odpovídající této šabloně", + "Ve formátu MS Excel odpovídající této šabloně", "vocabulary.summary.import.excel.template.tooltip": "Stáhnout šablonu pro MS Excel", "vocabulary.summary.import.nonEmpty.warning": "Slovník není prázdný, stávající data budou přepsána importovanými.", + "vocabulary.summary.import.translations.label": + "Nahrajte soubor ve formátu MS Excel odpovídající této šabloně, ze kterého mají být naimportovány překlady existujících pojmů ve slovníku.", + "vocabulary.summary.import.translations.help": + "Stávající data nebudou přepsána.", "vocabulary.import.type.skos": "SKOS", "vocabulary.import.type.excel": "MS Excel", "vocabulary.import.action": "Importovat", @@ -358,6 +364,7 @@ const cs = { "Textová analýza definic pojmů ve všech slovnících spuštěna.", "vocabulary.termchanges.creations": "Vytvořené pojmy", "vocabulary.termchanges.updates": "Aktualizované pojmy", + "vocabulary.termchanges.deletions": "Smazané pojmy", "vocabulary.termchanges.termcount": "Počet změněných pojmů", "vocabulary.termchanges.loading": "Načítám změny ...", "vocabulary.termchanges.empty": @@ -622,6 +629,7 @@ const cs = { "file.upload.hint": "Maximální velikost souboru: {maxUploadFileSize}. Má-li být soubor použit pro extrakci pojmů do slovníku, musí být ve formátu UTF-8, nebo validní MS Excel.", "file.upload.size.exceeded": "Soubor je příliš velký.", + "file.language": "Jazyk obsahu souboru", "dataset.license": "Licence", "dataset.format": "Formát", @@ -814,6 +822,11 @@ const cs = { 'Neplatný identifikátor: "{uri}", neočekávaný znak "{char}" na pozici {index}.', "error.invalidIdentifier": 'Neplatný identifikátor: "{uri}"', + "error.annotation.file.unsupportedLanguage": + "Služba textové analýza nepodporuje jazyk obsahu souboru.", + "error.annotation.term.unsupportedLanguage": + "Služba textové analýza nepodporuje jazyk definice pojmu.", + "history.label": "Historie změn", "history.loading": "Načítám historii...", "history.empty": "Zaznamenaná historie je prázdná.", @@ -821,6 +834,7 @@ const cs = { "history.type": "Typ", "history.type.persist": "Vytvoření", "history.type.update": "Změna", + "history.type.delete": "Smazání", "history.changedAttribute": "Atribut", "history.originalValue": "Původní hodnota", "history.newValue": "Nová hodnota", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ea573359..264ae219 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -297,21 +297,27 @@ const en = { "vocabulary.summary.export.rdfxml.title": "Export to RDF/XML (RDF).", "vocabulary.summary.export.error": "Unable to retrieve exported data from server response.", - "vocabulary.summary.import.action": "Restore from backup", + "vocabulary.summary.import.action": "Load from file", "vocabulary.summary.import.action.tooltip": - "Restore the vocabulary from its previously exported version", + "Load vocabulary data from a file containing data in SKOS or MS Excel format", "vocabulary.summary.import.dialog.title": - "Restore exported vocabulary version", + "Import vocabulary content from file", + "vocabulary.summary.import.dialog.tab.replaceContent": "Replace content", + "vocabulary.summary.import.dialog.tab.translations": "Import translations", "vocabulary.summary.import.dialog.label": "Upload an exported version of this vocabulary", "vocabulary.summary.import.dialog.skosImport": "In the SKOS format and containing a single skos:ConceptScheme with IRI '<'IRI-OF-THIS-VOCABULARY'>'/glosář", "vocabulary.summary.import.dialog.excelImport": - "MS Excel file corresponding to this template", + "As an MS Excel file corresponding to this template", "vocabulary.summary.import.excel.template.tooltip": "Download a MS Excel template", "vocabulary.summary.import.nonEmpty.warning": "Vocabulary is not empty, existing data will be overwritten by the imported.", + "vocabulary.summary.import.translations.label": + "Upload an MS Excel file corresponding to this template from which translations of existing terms in this vocabulary will be imported.", + "vocabulary.summary.import.translations.help": + "Existing data will not be changed.", "vocabulary.import.type.skos": "SKOS", "vocabulary.import.type.excel": "MS Excel", "vocabulary.import.action": "Import", @@ -351,6 +357,7 @@ const en = { "Text analysis of terms' definitions in all vocabularies invoked.", "vocabulary.termchanges.creations": "Created terms", "vocabulary.termchanges.updates": "Updated terms", + "vocabulary.termchanges.deletions": "Deleted terms", "vocabulary.termchanges.termcount": "Changed term count", "vocabulary.termchanges.loading": "Loading changes ...", "vocabulary.termchanges.empty": "No creations/updates of terms found.", @@ -613,6 +620,7 @@ const en = { "file.upload.hint": "Maximum file size: {maxUploadFileSize}. To use the file for term extraction, it must be in UTF-8 or a valid MS Excel file.", "file.upload.size.exceeded": "File is too large.", + "file.language": "File content language", "dataset.license": "License", "dataset.format": "Format", @@ -806,6 +814,11 @@ const en = { 'Invalid identifier: "{uri}", unexpected character "{char}" at {index}.', "error.invalidIdentifier": 'Invalid identifier: "{uri}"', + "error.annotation.file.unsupportedLanguage": + "Text analysis service does not support the language of this file.", + "error.annotation.term.unsupportedLanguage": + "Text analysis service does not support the language of this term's definition.", + "history.label": "Change history", "history.loading": "Loading history...", "history.empty": "The recorded history of this asset is empty.", @@ -813,6 +826,7 @@ const en = { "history.type": "Type", "history.type.persist": "Creation", "history.type.update": "Update", + "history.type.delete": "Deletion", "history.changedAttribute": "Attribute", "history.originalValue": "Original value", "history.newValue": "New value", diff --git a/src/model/File.ts b/src/model/File.ts index 6833cc17..16bad627 100644 --- a/src/model/File.ts +++ b/src/model/File.ts @@ -6,6 +6,7 @@ import VocabularyUtils from "../util/VocabularyUtils"; const ctx = { content: VocabularyUtils.CONTENT, owner: VocabularyUtils.IS_PART_OF_DOCUMENT, + language: VocabularyUtils.DC_LANGUAGE, }; /** @@ -18,18 +19,21 @@ export const OWN_CONTEXT = ctx; export interface FileData extends ResourceData { origin?: string; content?: string; + language?: string; owner?: DocumentData; } export default class File extends Resource implements FileData { public origin: string; public content?: string; + public language?: string; public owner?: DocumentData; constructor(data: FileData) { super(data); this.origin = data.origin ? data.origin : ""; this.content = data.content; + this.language = data.language; this.owner = data.owner; } diff --git a/src/model/Term.ts b/src/model/Term.ts index d13139e3..58d8cac5 100644 --- a/src/model/Term.ts +++ b/src/model/Term.ts @@ -113,8 +113,18 @@ export interface TermInfo { types?: string[]; } -export function termComparator(a: TermInfo | TermData, b: TermInfo | TermData) { - return getLocalized(a.label).localeCompare(getLocalized(b.label)); +/** + * Creates a localized Term comparator. + * + * I.e., the comparator uses term labels in the specified language and compares them based on the specified language/locale. + * @param lang Language (locale) for comparison, e.g., en, cs + */ +export function createTermComparator(lang?: string) { + return (a: TermInfo | TermData, b: TermInfo | TermData) => { + const aLabel = (getLocalized(a.label, lang) || "").toLowerCase(); + const bLabel = (getLocalized(b.label, lang) || "").toLowerCase(); + return aLabel.localeCompare(bLabel, lang); + }; } declare type TermMap = { [key: string]: Term }; @@ -305,7 +315,8 @@ export default class Term } public static consolidateRelatedAndRelatedMatch( - term: Term | TermData + term: Term | TermData, + lang?: string ): TermInfo[] { const result = [...Utils.sanitizeArray(term.relatedTerms)]; for (let rt of Utils.sanitizeArray(term.relatedMatchTerms)) { @@ -313,7 +324,7 @@ export default class Term result.push(rt); } } - result.sort(termComparator); + result.sort(createTermComparator(lang)); return result; } } diff --git a/src/model/TextAnalysisRecord.ts b/src/model/TextAnalysisRecord.ts index de1c1efd..62b16453 100644 --- a/src/model/TextAnalysisRecord.ts +++ b/src/model/TextAnalysisRecord.ts @@ -6,6 +6,7 @@ import VocabularyUtils from "../util/VocabularyUtils"; const ctx = { vocabularies: `${VocabularyUtils.NS_TERMIT}m\u00e1-slovn\u00edk-pro-anal\u00fdzu`, analyzedResource: `${VocabularyUtils.NS_TERMIT}m\u00e1-analyzovan\u00fd-zdroj`, + language: VocabularyUtils.DC_LANGUAGE, }; export const CONTEXT = Object.assign({}, ctx, RESOURCE_CONTEXT); @@ -15,6 +16,7 @@ export interface TextAnalysisRecordData { analyzedResource: ResourceData; vocabularies: AssetData[]; created: string; + language?: string; } export class TextAnalysisRecord implements TextAnalysisRecordData { @@ -22,11 +24,13 @@ export class TextAnalysisRecord implements TextAnalysisRecordData { public readonly analyzedResource: ResourceData; public readonly vocabularies: AssetData[]; public readonly created: string; + public readonly language?: string; constructor(data: TextAnalysisRecordData) { this.iri = data.iri; this.analyzedResource = data.analyzedResource; this.created = data.created; + this.language = data.language; this.vocabularies = Utils.sanitizeArray(data.vocabularies); } } diff --git a/src/model/__tests__/Term.test.ts b/src/model/__tests__/Term.test.ts index c0005a73..e2b7d50b 100644 --- a/src/model/__tests__/Term.test.ts +++ b/src/model/__tests__/Term.test.ts @@ -368,6 +368,27 @@ describe("Term tests", () => { ...t.relatedTerms, ]); }); + + it("sorts terms by label respecting specified language", () => { + const t = new Term(termData); + const t1Uri = Generator.generateUri(); + const t2Uri = Generator.generateUri(); + t.relatedTerms = [ + { + iri: t1Uri, + label: langString("chalupa", "cs"), + vocabulary: { iri: t.vocabulary!.iri }, + }, + { + iri: t2Uri, + label: langString("činitel", "cs"), + vocabulary: { iri: t.vocabulary!.iri }, + }, + ]; + const result = Term.consolidateRelatedAndRelatedMatch(t, "cs"); + expect(result[0].iri).toEqual(t2Uri); + expect(result[1].iri).toEqual(t1Uri); + }); }); describe("isSnapshot", () => { diff --git a/src/model/changetracking/ChangeRecord.ts b/src/model/changetracking/ChangeRecord.ts index b06c268d..4101fb6a 100644 --- a/src/model/changetracking/ChangeRecord.ts +++ b/src/model/changetracking/ChangeRecord.ts @@ -1,6 +1,7 @@ import VocabularyUtils from "../../util/VocabularyUtils"; import User, { CONTEXT as USER_CONTEXT, UserData } from "../User"; import Utils from "../../util/Utils"; +import { context } from "../MultilingualString"; const ctx = { timestamp: { @@ -12,6 +13,7 @@ const ctx = { changedAttribute: `${VocabularyUtils.PREFIX}m\u00e1-zm\u011bn\u011bn\u00fd-atribut`, originalValue: `${VocabularyUtils.PREFIX}m\u00e1-p\u016fvodn\u00ed-hodnotu`, newValue: `${VocabularyUtils.PREFIX}m\u00e1-novou-hodnotu`, + label: context(VocabularyUtils.RDFS_LABEL), }; export const CONTEXT = Object.assign({}, ctx, USER_CONTEXT); diff --git a/src/model/changetracking/DeleteRecord.ts b/src/model/changetracking/DeleteRecord.ts new file mode 100644 index 00000000..1eb257d2 --- /dev/null +++ b/src/model/changetracking/DeleteRecord.ts @@ -0,0 +1,21 @@ +import ChangeRecord, { ChangeRecordData } from "./ChangeRecord"; +import MultilingualString from "../MultilingualString"; + +export interface DeleteRecordData extends ChangeRecordData { + label: MultilingualString; +} + +/** + * Represents insertion of an entity into the repository. + */ +export default class DeleteRecord extends ChangeRecord { + public readonly label: MultilingualString; + public constructor(data: DeleteRecordData) { + super(data); + this.label = data.label; + } + + get typeLabel(): string { + return "history.type.delete"; + } +} diff --git a/src/model/filter/VocabularyContentChangeFilterData.ts b/src/model/filter/VocabularyContentChangeFilterData.ts new file mode 100644 index 00000000..b399fd9b --- /dev/null +++ b/src/model/filter/VocabularyContentChangeFilterData.ts @@ -0,0 +1,22 @@ +import VocabularyUtils from "../../util/VocabularyUtils"; + +export interface VocabularyContentChangeFilterData { + author: string; + term: string; + changeType: string; + attribute: string; +} + +export function getChangeTypeUri( + filterData: VocabularyContentChangeFilterData +): string { + switch (filterData.changeType) { + 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 ""; +} diff --git a/src/reducer/TermItReducers.ts b/src/reducer/TermItReducers.ts index baebede5..fe3abb6f 100644 --- a/src/reducer/TermItReducers.ts +++ b/src/reducer/TermItReducers.ts @@ -126,7 +126,7 @@ function vocabulary( switch (action.type) { case ActionType.LOAD_VOCABULARY: if (action.status === AsyncActionStatus.REQUEST) { - return EMPTY_VOCABULARY; + return (action as any).iri === state.iri ? state : EMPTY_VOCABULARY; } else if (isAsyncSuccess(action)) { return action.payload as Vocabulary; } else { @@ -144,11 +144,6 @@ function vocabulary( return onTermCountLoaded(state, action); case ActionType.LOGOUT: return EMPTY_VOCABULARY; - case ActionType.REMOVE_RESOURCE: - case ActionType.UPDATE_RESOURCE: - case ActionType.CREATE_RESOURCE: // intentional fall-through - // the resource might have been/be related to the vocabulary - return isAsyncSuccess(action) ? EMPTY_VOCABULARY : state; default: return state; } diff --git a/src/reducer/__tests__/TermItReducers.test.ts b/src/reducer/__tests__/TermItReducers.test.ts index 76949d80..13be8dae 100644 --- a/src/reducer/__tests__/TermItReducers.test.ts +++ b/src/reducer/__tests__/TermItReducers.test.ts @@ -350,36 +350,6 @@ describe("Reducers", () => { expect(vocabulary.allImportedVocabularies).toEqual(imports); }); - it("resets vocabulary to empty when resource is removed", () => { - // The removed resource could have been a file from a document related to that vocabulary, in which case - // the vocabulary needs to be reloaded - const action = { type: ActionType.REMOVE_RESOURCE }; - initialState.vocabulary = new Vocabulary({ - label: langString("Test vocabulary"), - iri: Generator.generateUri(), - types: [VocabularyUtils.VOCABULARY], - }); - expect( - reducers(stateToPlainObject(initialState), asyncActionSuccess(action)) - .vocabulary - ).toEqual(EMPTY_VOCABULARY); - }); - - it("resets vocabulary to empty when resource is created", () => { - // The created resource could be a file added to a document related to that vocabulary, in which case - // the vocabulary needs to be reloaded - const action = { type: ActionType.CREATE_RESOURCE }; - initialState.vocabulary = new Vocabulary({ - label: langString("Test vocabulary"), - iri: Generator.generateUri(), - types: [VocabularyUtils.VOCABULARY], - }); - expect( - reducers(stateToPlainObject(initialState), asyncActionSuccess(action)) - .vocabulary - ).toEqual(EMPTY_VOCABULARY); - }); - it("sets term count on vocabulary when it is loaded", () => { initialState.vocabulary = new Vocabulary({ label: langString("Test vocabulary"), diff --git a/src/util/AssetFactory.ts b/src/util/AssetFactory.ts index 73b67d49..27c961fe 100644 --- a/src/util/AssetFactory.ts +++ b/src/util/AssetFactory.ts @@ -23,6 +23,9 @@ import { UserGroupAccessControlRecord, UserRoleAccessControlRecord, } from "../model/acl/AccessControlList"; +import DeleteRecord, { + DeleteRecordData, +} from "../model/changetracking/DeleteRecord"; const AssetFactory = { /** @@ -117,6 +120,8 @@ const AssetFactory = { return new PersistRecord(data); } else if (data.types.indexOf(VocabularyUtils.UPDATE_EVENT) !== -1) { return new UpdateRecord(data as UpdateRecordData); + } else if (data.types.indexOf(VocabularyUtils.DELETE_EVENT) !== -1) { + return new DeleteRecord(data as DeleteRecordData); } throw new TypeError( "Unsupported type of change record data " + JSON.stringify(data) diff --git a/src/util/IntlUtil.ts b/src/util/IntlUtil.ts index 3c5631dd..f3306787 100644 --- a/src/util/IntlUtil.ts +++ b/src/util/IntlUtil.ts @@ -2,6 +2,7 @@ import Constants from "./Constants"; import IntlData from "../model/IntlData"; import BrowserStorage from "./BrowserStorage"; import Utils from "./Utils"; +import ISO6391 from "iso-639-1"; export function loadInitialLocalizationData(): IntlData { const prefLang = BrowserStorage.get(Constants.STORAGE_LANG_KEY); @@ -88,3 +89,38 @@ export function removeTranslation( } }); } + +/** + * Type representing language data in an asset language selector. + */ +export interface Language { + code: string; + name: string; + nativeName: string; +} + +function prioritizeLanguages(options: Language[], languages: string[]) { + languages.forEach((lang) => { + const ind = options.findIndex((v) => v.code === lang); + const option = options[ind]; + options.splice(ind, 1); + options.unshift(option); + }); + return options; +} + +const LANGUAGE_OPTIONS = prioritizeLanguages( + ISO6391.getLanguages(ISO6391.getAllCodes()), + Object.getOwnPropertyNames(Constants.LANG).map((lang) => + getShortLocale(Constants.LANG[lang].locale) + ) +); + +/** + * Gets a list of all possible languages. + * + * The languages are retrieved using the iso-639-1 JS library. + */ +export function getLanguageOptions(): Language[] { + return LANGUAGE_OPTIONS; +} diff --git a/src/util/Utils.ts b/src/util/Utils.ts index b4fd2cb9..e1134388 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -3,8 +3,6 @@ */ import Asset, { HasLabel, HasTypes } from "../model/Asset"; import VocabularyUtils, { IRI, IRIImpl } from "./VocabularyUtils"; -import { match } from "react-router"; -import { Location } from "history"; import AppNotification, { AssetUpdateNotification, } from "../model/AppNotification"; @@ -90,17 +88,6 @@ const Utils = { return params[paramName]; }, - /** - * Extracts asset IRI from the specified route props. - * - * Uses match param {@code name} as fragment value. Namespace is extracted from location search string. - */ - extractAssetIri(routeMatch: match, location: Location) { - const namespace = this.extractQueryParam(location.search, "namespace"); - const normalizedName = routeMatch.params.name; - return { fragment: normalizedName, namespace }; - }, - /** * Ensures that file download using Ajax triggers browser file save mechanism. * @@ -368,6 +355,20 @@ const Utils = { notBlank(str?: string | null) { return !!(str && str.trim().length > 0); }, + + shrinkFullIri(iri: string): string { + if (iri.indexOf("://") === -1) { + return iri; // It is prefixed + } + const lastSlashIndex = iri.lastIndexOf("/"); + const lastHashIndex = iri.lastIndexOf("#"); + return ( + "..." + + iri.substring( + lastHashIndex > lastSlashIndex ? lastHashIndex : lastSlashIndex + ) + ); + }, }; export default Utils; diff --git a/src/util/VocabularyUtils.ts b/src/util/VocabularyUtils.ts index bf8ef33d..bb21e375 100644 --- a/src/util/VocabularyUtils.ts +++ b/src/util/VocabularyUtils.ts @@ -160,6 +160,7 @@ const VocabularyUtils = { PERSIST_EVENT: `${_NS_POPIS_DAT}vytvo\u0159en\u00ed-entity`, UPDATE_EVENT: `${_NS_POPIS_DAT}\u00faprava-entity`, + DELETE_EVENT: `${_NS_POPIS_DAT}smaz\u00e1n\u00ed-entity`, TERM_SNAPSHOT: _NS_POPIS_DAT + "verze-pojmu", VOCABULARY_SNAPSHOT: _NS_POPIS_DAT + "verze-slovníku",