mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-19 14:37:52 +00:00
Revamp Glossary UI to support URL updation (#3453)
This commit is contained in:
parent
22b394d63f
commit
cbe667b7cb
@ -19,6 +19,9 @@ node_modules/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# mockups
|
||||
mock-api/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
@ -19,6 +19,9 @@ node_modules/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# mockups
|
||||
mock-api/
|
||||
|
||||
# Ignore files (Prettier has trouble parsing files without extension)
|
||||
.gitignore
|
||||
.prettierignore
|
||||
|
@ -21,12 +21,13 @@ import {
|
||||
getGlossariesByName,
|
||||
getGlossaryTermByFQN,
|
||||
} from '../../axiosAPIs/glossaryAPI';
|
||||
import { ROUTES } from '../../constants/constants';
|
||||
import { getGlossaryPath } from '../../constants/constants';
|
||||
import { CreateGlossaryTerm } from '../../generated/api/data/createGlossaryTerm';
|
||||
import { Glossary } from '../../generated/entity/data/glossary';
|
||||
import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm';
|
||||
import { useAuth } from '../../hooks/authHooks';
|
||||
import useToastContext from '../../hooks/useToastContext';
|
||||
import jsonData from '../../jsons/en';
|
||||
import AddGlossaryTerm from '../AddGlossaryTerm/AddGlossaryTerm.component';
|
||||
import PageContainerV1 from '../containers/PageContainerV1';
|
||||
import Loader from '../Loader/Loader';
|
||||
@ -44,43 +45,65 @@ const AddGlossaryTermPage = () => {
|
||||
|
||||
const [parentGlossaryData, setParentGlossaryData] = useState<GlossaryTerm>();
|
||||
|
||||
const goToGlossary = () => {
|
||||
history.push(ROUTES.GLOSSARY);
|
||||
const goToGlossary = (name = '') => {
|
||||
history.push(getGlossaryPath(name));
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
goToGlossary();
|
||||
};
|
||||
|
||||
const handleShowErrorToast = (errMessage: string) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: errMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveFailure = (errorMessage = '') => {
|
||||
handleShowErrorToast(
|
||||
errorMessage || jsonData['api-error-messages']['add-glossary-term-error']
|
||||
);
|
||||
setStatus('initial');
|
||||
};
|
||||
|
||||
const onSave = (data: CreateGlossaryTerm) => {
|
||||
setStatus('waiting');
|
||||
addGlossaryTerm(data)
|
||||
.then(() => {
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
setStatus('initial');
|
||||
goToGlossary();
|
||||
}, 500);
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
setStatus('initial');
|
||||
goToGlossary(res?.data?.fullyQualifiedName);
|
||||
}, 500);
|
||||
} else {
|
||||
handleSaveFailure();
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.message || 'Something went wrong!',
|
||||
});
|
||||
setStatus('initial');
|
||||
handleSaveFailure(err.response?.data?.message);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchGlossaryData = () => {
|
||||
getGlossariesByName(glossaryName, ['tags', 'owner', 'reviewers'])
|
||||
.then((res: AxiosResponse) => {
|
||||
setGlossaryData(res.data);
|
||||
if (res.data) {
|
||||
setGlossaryData(res.data);
|
||||
} else {
|
||||
setGlossaryData(undefined);
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['fetch-glossary-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.message || 'Error while fetching glossary!',
|
||||
});
|
||||
setGlossaryData(undefined);
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['fetch-glossary-error']
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
@ -93,14 +116,21 @@ const AddGlossaryTermPage = () => {
|
||||
'tags',
|
||||
])
|
||||
.then((res: AxiosResponse) => {
|
||||
setParentGlossaryData(res.data);
|
||||
if (res.data) {
|
||||
setParentGlossaryData(res.data);
|
||||
} else {
|
||||
setParentGlossaryData(undefined);
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['fetch-glossary-term-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
setParentGlossaryData(undefined);
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.message || 'Error while fetching glossary terms!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['fetch-glossary-term-error']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -58,25 +58,13 @@ type Props = {
|
||||
handleAddGlossaryTermClick: () => void;
|
||||
updateGlossary: (value: Glossary) => void;
|
||||
handleGlossaryTermUpdate: (value: GlossaryTerm) => void;
|
||||
handleSelectedData: (
|
||||
data: Glossary | GlossaryTerm,
|
||||
pos: string,
|
||||
key: string
|
||||
) => void;
|
||||
handleSelectedData: (key: string) => void;
|
||||
handleChildLoading: (status: boolean) => void;
|
||||
handleSearchText: (text: string) => void;
|
||||
onGlossaryDelete: (id: string) => void;
|
||||
onGlossaryTermDelete: (id: string) => void;
|
||||
onAssetPaginate: (num: number) => void;
|
||||
isChildLoading: boolean;
|
||||
// handlePathChange: (
|
||||
// glossary: string,
|
||||
// glossaryTermsFQN?: string | undefined
|
||||
// ) => void;
|
||||
};
|
||||
|
||||
type ModifiedDataNode = DataNode & {
|
||||
data: Glossary | GlossaryTerm;
|
||||
};
|
||||
|
||||
const GlossaryV1 = ({
|
||||
@ -163,14 +151,8 @@ Props) => {
|
||||
const key = node.key as string;
|
||||
if (selectedKey !== key) {
|
||||
handleChildLoading(true);
|
||||
const breadCrumbData = (treeRef.current?.state.keyEntities[key].nodes ||
|
||||
[]) as ModifiedDataNode[];
|
||||
const selData = breadCrumbData[breadCrumbData.length - 1].data;
|
||||
const pos = treeRef.current?.state.keyEntities[key].pos;
|
||||
handleSelectedData(selData, pos as string, key);
|
||||
handleSelectedData(key);
|
||||
}
|
||||
// handlePathChange(key.split('.')[0], key);
|
||||
// handleSelectedKey(key);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -295,8 +295,14 @@ export const getUserPath = (username: string) => {
|
||||
return path;
|
||||
};
|
||||
|
||||
export const getGlossaryPath = () => {
|
||||
return ROUTES.GLOSSARY;
|
||||
export const getGlossaryPath = (fqn?: string) => {
|
||||
let path = ROUTES.GLOSSARY;
|
||||
if (fqn) {
|
||||
path = ROUTES.GLOSSARY_DETAILS;
|
||||
path = path.replace(PLACEHOLDER_GLOSSARY_NAME, fqn);
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
export const getGlossaryTermsPath = (
|
||||
|
31
openmetadata-ui/src/main/resources/ui/src/jsons/en.ts
Normal file
31
openmetadata-ui/src/main/resources/ui/src/jsons/en.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2021 Collate
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const jsonData = {
|
||||
'api-error-messages': {
|
||||
'add-glossary-error': 'Error while adding glossary!',
|
||||
'add-glossary-term-error': 'Error while adding glossary term!',
|
||||
'delete-glossary-error': 'Error while deleting glossary!',
|
||||
'delete-glossary-term-error': 'Error while deleting glossary term!',
|
||||
'elastic-search-error': 'Error while fetch data from Elasticsearch!',
|
||||
'fetch-data-error': 'Error while fetching data!',
|
||||
'fetch-glossary-error': 'Error while fetching glossary!',
|
||||
'fetch-glossary-list-error': 'Error while fetching glossaries!',
|
||||
'fetch-glossary-term-error': 'Error while fetching glossary term!',
|
||||
'fetch-tags-error': 'Error while fetching tags!',
|
||||
'update-glossary-term-error': 'Error while updating glossary term!',
|
||||
'update-description-error': 'Error while updating description!',
|
||||
},
|
||||
};
|
||||
|
||||
export default jsonData;
|
@ -6,10 +6,11 @@ import { useAuthContext } from '../../auth-provider/AuthProvider';
|
||||
import { addGlossaries } from '../../axiosAPIs/glossaryAPI';
|
||||
import AddGlossary from '../../components/AddGlossary/AddGlossary.component';
|
||||
import PageContainerV1 from '../../components/containers/PageContainerV1';
|
||||
import { ROUTES } from '../../constants/constants';
|
||||
import { getGlossaryPath } from '../../constants/constants';
|
||||
import { CreateGlossary } from '../../generated/api/data/createGlossary';
|
||||
import { useAuth } from '../../hooks/authHooks';
|
||||
import useToastContext from '../../hooks/useToastContext';
|
||||
import jsonData from '../../jsons/en';
|
||||
import { getTagCategories, getTaglist } from '../../utils/TagsUtils';
|
||||
|
||||
const AddGlossaryPage: FunctionComponent = () => {
|
||||
@ -21,30 +22,44 @@ const AddGlossaryPage: FunctionComponent = () => {
|
||||
const [isTagLoading, setIsTagLoading] = useState<boolean>(false);
|
||||
const [status, setStatus] = useState<LoadingState>('initial');
|
||||
|
||||
const goToGlossary = () => {
|
||||
history.push(ROUTES.GLOSSARY);
|
||||
const goToGlossary = (name = '') => {
|
||||
history.push(getGlossaryPath(name));
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
goToGlossary();
|
||||
};
|
||||
|
||||
const handleShowErrorToast = (errMessage: string) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: errMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveFailure = (errorMessage = '') => {
|
||||
handleShowErrorToast(
|
||||
errorMessage || jsonData['api-error-messages']['add-glossary-error']
|
||||
);
|
||||
setStatus('initial');
|
||||
};
|
||||
|
||||
const onSave = (data: CreateGlossary) => {
|
||||
setStatus('waiting');
|
||||
addGlossaries(data)
|
||||
.then(() => {
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
setStatus('initial');
|
||||
goToGlossary();
|
||||
}, 500);
|
||||
.then((res) => {
|
||||
if (res.data) {
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
setStatus('initial');
|
||||
goToGlossary(res.data.name);
|
||||
}, 500);
|
||||
} else {
|
||||
handleSaveFailure();
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.message || 'Something went wrong!',
|
||||
});
|
||||
setStatus('initial');
|
||||
handleSaveFailure(err.response?.data?.message);
|
||||
});
|
||||
};
|
||||
|
||||
@ -52,7 +67,19 @@ const AddGlossaryPage: FunctionComponent = () => {
|
||||
setIsTagLoading(true);
|
||||
getTagCategories()
|
||||
.then((res) => {
|
||||
setTagList(getTaglist(res.data));
|
||||
if (res.data) {
|
||||
setTagList(getTaglist(res.data));
|
||||
} else {
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['fetch-tags-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['fetch-tags-error']
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsTagLoading(false);
|
||||
|
@ -1,8 +1,27 @@
|
||||
import { findByText, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import GlossaryPage from './GlossaryPage.component';
|
||||
import GlossaryPageV1 from './GlossaryPageV1.component';
|
||||
|
||||
jest.mock('../../components/Glossary/Glossary.component', () => {
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useHistory: jest.fn(),
|
||||
useParams: jest.fn().mockReturnValue({
|
||||
glossaryName: 'GlossaryName',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../auth-provider/AuthProvider', () => {
|
||||
return {
|
||||
useAuthContext: jest.fn(() => ({
|
||||
isAuthDisabled: false,
|
||||
isAuthenticated: true,
|
||||
isProtectedRoute: jest.fn().mockReturnValue(true),
|
||||
isTourRoute: jest.fn().mockReturnValue(false),
|
||||
onLogoutHandler: jest.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../components/Glossary/GlossaryV1.component', () => {
|
||||
return jest.fn().mockReturnValue(<div>Glossary.component</div>);
|
||||
});
|
||||
|
||||
@ -12,7 +31,7 @@ jest.mock('../../axiosAPIs/glossaryAPI', () => ({
|
||||
|
||||
describe('Test GlossaryComponent page', () => {
|
||||
it('GlossaryComponent Page Should render', async () => {
|
||||
const { container } = render(<GlossaryPage />);
|
||||
const { container } = render(<GlossaryPageV1 />);
|
||||
|
||||
const glossaryComponent = await findByText(
|
||||
container,
|
||||
|
@ -13,7 +13,7 @@
|
||||
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { compare } from 'fast-json-patch';
|
||||
import { cloneDeep, extend } from 'lodash';
|
||||
import { cloneDeep, extend, isEmpty } from 'lodash';
|
||||
import {
|
||||
FormattedGlossarySuggestion,
|
||||
GlossarySuggestionHit,
|
||||
@ -22,7 +22,7 @@ import {
|
||||
SearchResponse,
|
||||
} from 'Models';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useAuthContext } from '../../auth-provider/AuthProvider';
|
||||
import {
|
||||
deleteGlossary,
|
||||
@ -38,6 +38,7 @@ import GlossaryV1 from '../../components/Glossary/GlossaryV1.component';
|
||||
import Loader from '../../components/Loader/Loader';
|
||||
import {
|
||||
getAddGlossaryTermsPath,
|
||||
getGlossaryPath,
|
||||
PAGE_SIZE,
|
||||
ROUTES,
|
||||
} from '../../constants/constants';
|
||||
@ -47,11 +48,14 @@ import { Glossary } from '../../generated/entity/data/glossary';
|
||||
import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm';
|
||||
import { useAuth } from '../../hooks/authHooks';
|
||||
import useToastContext from '../../hooks/useToastContext';
|
||||
import jsonData from '../../jsons/en';
|
||||
import { formatDataResponse } from '../../utils/APIUtils';
|
||||
import {
|
||||
getChildGlossaryTerms,
|
||||
getGlossariesWithRootTerms,
|
||||
getHierarchicalKeysByFQN,
|
||||
getTermDataFromGlossary,
|
||||
getTermPosFromGlossaries,
|
||||
updateGlossaryListBySearchedTerms,
|
||||
} from '../../utils/GlossaryUtils';
|
||||
|
||||
@ -60,8 +64,7 @@ export type ModifiedGlossaryData = Glossary & {
|
||||
};
|
||||
|
||||
const GlossaryPageV1 = () => {
|
||||
// const { glossaryName, glossaryTermsFQN } =
|
||||
// useParams<{ [key: string]: string }>();
|
||||
const { glossaryName } = useParams<Record<string, string>>();
|
||||
|
||||
const { isAdminUser } = useAuth();
|
||||
const { isAuthDisabled } = useAuthContext();
|
||||
@ -88,6 +91,13 @@ const GlossaryPageV1 = () => {
|
||||
currPage: 1,
|
||||
});
|
||||
|
||||
const handleShowErrorToast = (errMessage: string) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: errMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChildLoading = (status: boolean) => {
|
||||
setIsChildLoading(status);
|
||||
};
|
||||
@ -100,6 +110,16 @@ const GlossaryPageV1 = () => {
|
||||
setExpandedKey(key);
|
||||
};
|
||||
|
||||
const handleSearchText = (text: string) => {
|
||||
setSearchText(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects glossary after fetching list
|
||||
* if no fqn is present in route params
|
||||
* @param data Glossary to be selected initially
|
||||
* @param noSetData bool to decide if data is already set
|
||||
*/
|
||||
const initSelectGlossary = (data: Glossary, noSetData = false) => {
|
||||
if (!noSetData) {
|
||||
setSelectedData(data);
|
||||
@ -109,47 +129,24 @@ const GlossaryPageV1 = () => {
|
||||
setExpandedKey([data.name]);
|
||||
};
|
||||
|
||||
const fetchGlossaryList = (paging = '') => {
|
||||
setIsLoading(true);
|
||||
getGlossariesWithRootTerms(paging, 100, ['owner', 'tags', 'reviewers'])
|
||||
.then((data: ModifiedGlossaryData[]) => {
|
||||
if (data?.length) {
|
||||
setGlossaries(data);
|
||||
setGlossariesList(data);
|
||||
initSelectGlossary(data[0]);
|
||||
} else {
|
||||
setGlossariesList([]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.response?.data?.message ?? 'Something went wrong!',
|
||||
});
|
||||
setIsLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
handleChildLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To fetch glossary term data
|
||||
* @param fqn fullyQualifiedName of term
|
||||
* @param pos hierarchical position of term in existing tree
|
||||
* @param arrGlossary list of available/fetched glossaries
|
||||
*/
|
||||
const fetchGlossaryTermByName = (
|
||||
name: string,
|
||||
pos: string[],
|
||||
key?: string
|
||||
fqn: string,
|
||||
pos: number[],
|
||||
arrGlossary: ModifiedGlossaryData[]
|
||||
) => {
|
||||
getGlossaryTermByFQN(name, [
|
||||
'children',
|
||||
'relatedTerms',
|
||||
'reviewers',
|
||||
'tags',
|
||||
])
|
||||
getGlossaryTermByFQN(fqn, ['children', 'relatedTerms', 'reviewers', 'tags'])
|
||||
.then(async (res: AxiosResponse) => {
|
||||
const { data } = res;
|
||||
if (data) {
|
||||
const clonedGlossaryList = cloneDeep(glossariesList);
|
||||
let treeNode = clonedGlossaryList[+pos[0]];
|
||||
const clonedGlossaryList = cloneDeep(arrGlossary);
|
||||
let treeNode = clonedGlossaryList[pos[0]];
|
||||
|
||||
for (let i = 1; i < pos.length; i++) {
|
||||
if (treeNode.children) {
|
||||
treeNode = treeNode.children[+pos[i]] as ModifiedGlossaryData;
|
||||
@ -193,29 +190,188 @@ const GlossaryPageV1 = () => {
|
||||
extend(treeNode, { ...data, children });
|
||||
|
||||
setSelectedData(data);
|
||||
if (key) {
|
||||
handleSelectedKey(key);
|
||||
if (fqn) {
|
||||
if (!expandedKey.length) {
|
||||
setExpandedKey(getHierarchicalKeysByFQN(fqn));
|
||||
}
|
||||
handleSelectedKey(fqn);
|
||||
}
|
||||
setGlossariesList(clonedGlossaryList);
|
||||
setIsGlossaryActive(false);
|
||||
} else {
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['fetch-glossary-term-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body:
|
||||
err.response?.data?.message ??
|
||||
'Error while fetching glossary terms!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['fetch-glossary-term-error']
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
handleChildLoading(false);
|
||||
setLoadingKey((pre) => {
|
||||
return pre.filter((item) => item !== key);
|
||||
return pre.filter((item) => item !== fqn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To fetch Assets using glossary term
|
||||
* @param fqn fullyQualifiedName of term
|
||||
* @param forceReset bool to reset the page to 1, incase of change in glossary term
|
||||
*/
|
||||
const fetchGlossaryTermAssets = (fqn: string, forceReset = false) => {
|
||||
if (fqn) {
|
||||
const tagName = fqn;
|
||||
searchData(
|
||||
'',
|
||||
forceReset ? 1 : assetData.currPage,
|
||||
PAGE_SIZE,
|
||||
`(tags:"${tagName}")`,
|
||||
'',
|
||||
'',
|
||||
myDataSearchIndex
|
||||
)
|
||||
.then((res: SearchResponse) => {
|
||||
const hits = res?.data?.hits?.hits;
|
||||
if (hits?.length > 0) {
|
||||
setAssetData((pre) => {
|
||||
const data = formatDataResponse(hits);
|
||||
const total = res.data.hits.total.value;
|
||||
|
||||
return forceReset
|
||||
? {
|
||||
data,
|
||||
total,
|
||||
currPage: 1,
|
||||
}
|
||||
: { ...pre, data, total };
|
||||
});
|
||||
} else {
|
||||
setAssetData((pre) => {
|
||||
const data = [] as GlossaryTermAssets['data'];
|
||||
const total = 0;
|
||||
|
||||
return forceReset
|
||||
? {
|
||||
data,
|
||||
total,
|
||||
currPage: 1,
|
||||
}
|
||||
: { ...pre, data, total };
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['elastic-search-error']
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setAssetData({ data: [], total: 0, currPage: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* To select data based on glossary or term name
|
||||
* @param dataFQN fullyQualifiedName of glossary or term
|
||||
* @param arrGlossary list of available/fetched glossaries
|
||||
*/
|
||||
const selectDataByFQN = (
|
||||
dataFQN: string,
|
||||
arrGlossary: ModifiedGlossaryData[]
|
||||
) => {
|
||||
handleChildLoading(true);
|
||||
const hierarchy = getTermPosFromGlossaries(arrGlossary, dataFQN);
|
||||
if (hierarchy.length < 2) {
|
||||
setSelectedData(arrGlossary[hierarchy[0]]);
|
||||
handleSelectedKey(dataFQN);
|
||||
if (!expandedKey.length) {
|
||||
setExpandedKey([dataFQN]);
|
||||
}
|
||||
setIsGlossaryActive(true);
|
||||
setIsLoading(false);
|
||||
handleChildLoading(false);
|
||||
} else {
|
||||
setLoadingKey((pre) => {
|
||||
return !pre.includes(dataFQN) ? [...pre, dataFQN] : pre;
|
||||
});
|
||||
fetchGlossaryTermByName(dataFQN, hierarchy, arrGlossary);
|
||||
fetchGlossaryTermAssets(dataFQN, true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* To check if glossary/term already exists and add to tree if they don't
|
||||
* Then select the glossary/term by it's fqn
|
||||
* @param arrGlossary list of available/fetched glossaries
|
||||
* @param fqn fullyQualifiedName of glossary or term
|
||||
*/
|
||||
const checkAndFetchDataByFQN = (
|
||||
arrGlossary: ModifiedGlossaryData[],
|
||||
fqn: string
|
||||
) => {
|
||||
let modifiedData = cloneDeep(arrGlossary);
|
||||
const arrFQN = getHierarchicalKeysByFQN(fqn);
|
||||
const glossary: ModifiedGlossaryData | GlossaryTerm = modifiedData.find(
|
||||
(item) => item.name === arrFQN[0]
|
||||
) as ModifiedGlossaryData;
|
||||
const data = getTermDataFromGlossary(glossary, fqn);
|
||||
if (isEmpty(data)) {
|
||||
modifiedData = updateGlossaryListBySearchedTerms(modifiedData, [
|
||||
{ fqdn: arrFQN[arrFQN.length - 1] },
|
||||
] as FormattedGlossarySuggestion[]);
|
||||
}
|
||||
selectDataByFQN(fqn, modifiedData);
|
||||
};
|
||||
|
||||
/**
|
||||
* To fetch the list of all glossaries,
|
||||
* and check for selection if nested fqn available
|
||||
* @param termFqn fullyQualifiedName of term
|
||||
* @param paging cursor pagination
|
||||
*/
|
||||
const fetchGlossaryList = (termFqn = '', paging = '') => {
|
||||
setIsLoading(true);
|
||||
getGlossariesWithRootTerms(paging, 1000, ['owner', 'tags', 'reviewers'])
|
||||
.then((data: ModifiedGlossaryData[]) => {
|
||||
if (data?.length) {
|
||||
setGlossaries(data);
|
||||
setGlossariesList(data);
|
||||
if (termFqn) {
|
||||
checkAndFetchDataByFQN(data, termFqn);
|
||||
} else {
|
||||
initSelectGlossary(data[0]);
|
||||
setIsLoading(false);
|
||||
handleChildLoading(false);
|
||||
}
|
||||
} else {
|
||||
setGlossariesList([]);
|
||||
setIsLoading(false);
|
||||
handleChildLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['fetch-glossary-list-error']
|
||||
);
|
||||
setIsLoading(false);
|
||||
handleChildLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To update glossary tree based on searched terms
|
||||
* @param arrGlossaries list of glossaries
|
||||
* @param newGlossaries set of glossaries present in searched terms
|
||||
* @param searchedTerms list of formatted searched terms
|
||||
*/
|
||||
const getSearchedGlossaries = (
|
||||
arrGlossaries: ModifiedGlossaryData[],
|
||||
newGlossaries: string[],
|
||||
@ -247,6 +403,9 @@ const GlossaryPageV1 = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* To fetch terms based on search text
|
||||
*/
|
||||
const fetchSearchedTerms = useCallback(() => {
|
||||
if (searchText) {
|
||||
searchData(
|
||||
@ -298,6 +457,11 @@ const GlossaryPageV1 = () => {
|
||||
}
|
||||
}, [searchText]);
|
||||
|
||||
/**
|
||||
* To save updated glossary using patch method
|
||||
* @param updatedData glossary with new values
|
||||
* @returns promise of api response
|
||||
*/
|
||||
const saveUpdatedGlossaryData = (
|
||||
updatedData: Glossary
|
||||
): Promise<AxiosResponse> => {
|
||||
@ -309,6 +473,10 @@ const GlossaryPageV1 = () => {
|
||||
) as unknown as Promise<AxiosResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* To update glossary
|
||||
* @param updatedData glossary with new values
|
||||
*/
|
||||
const updateGlossary = (updatedData: Glossary) => {
|
||||
saveUpdatedGlossaryData(updatedData)
|
||||
.then((res: AxiosResponse) => {
|
||||
@ -337,17 +505,25 @@ const GlossaryPageV1 = () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['update-description-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body:
|
||||
err.response?.data?.message ?? 'Error while updating description!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['update-description-error']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To save updated glossary term using patch method
|
||||
* @param updatedData glossary term with new values
|
||||
* @returns promise of api response
|
||||
*/
|
||||
const saveUpdatedGlossaryTermData = (
|
||||
updatedData: GlossaryTerm
|
||||
): Promise<AxiosResponse> => {
|
||||
@ -359,20 +535,33 @@ const GlossaryPageV1 = () => {
|
||||
) as unknown as Promise<AxiosResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* To update glossary term
|
||||
* @param updatedData glossary term with new values
|
||||
*/
|
||||
const handleGlossaryTermUpdate = (updatedData: GlossaryTerm) => {
|
||||
saveUpdatedGlossaryTermData(updatedData)
|
||||
.then((res: AxiosResponse) => {
|
||||
setSelectedData(res.data);
|
||||
if (res.data) {
|
||||
setSelectedData(res.data);
|
||||
} else {
|
||||
handleShowErrorToast(
|
||||
jsonData['api-error-messages']['update-glossary-term-error']
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body:
|
||||
err.response?.data?.message ?? 'Error while updating glossaryTerm!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['update-glossary-term-error']
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To delete glossary by id
|
||||
* @param id glossary id
|
||||
*/
|
||||
const handleGlossaryDelete = (id: string) => {
|
||||
setDeleteStatus('waiting');
|
||||
deleteGlossary(id)
|
||||
@ -381,14 +570,18 @@ const GlossaryPageV1 = () => {
|
||||
fetchGlossaryList();
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.response?.data?.message ?? 'Something went wrong!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['delete-glossary-error']
|
||||
);
|
||||
setDeleteStatus('initial');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To delete glossary term by id
|
||||
* @param id glossary term id
|
||||
*/
|
||||
const handleGlossaryTermDelete = (id: string) => {
|
||||
setDeleteStatus('waiting');
|
||||
deleteGlossaryTerm(id)
|
||||
@ -397,18 +590,24 @@ const GlossaryPageV1 = () => {
|
||||
fetchGlossaryList();
|
||||
})
|
||||
.catch((err: AxiosError) => {
|
||||
showToast({
|
||||
variant: 'error',
|
||||
body: err.response?.data?.message ?? 'Something went wrong!',
|
||||
});
|
||||
handleShowErrorToast(
|
||||
err.response?.data?.message ||
|
||||
jsonData['api-error-messages']['delete-glossary-term-error']
|
||||
);
|
||||
setDeleteStatus('initial');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To redirect to add glossary page
|
||||
*/
|
||||
const handleAddGlossaryClick = () => {
|
||||
history.push(ROUTES.ADD_GLOSSARY);
|
||||
};
|
||||
|
||||
/**
|
||||
* To redirct to add glossary term page
|
||||
*/
|
||||
const handleAddGlossaryTermClick = () => {
|
||||
const activeTerm = selectedKey.split('.');
|
||||
const glossaryName = activeTerm[0];
|
||||
@ -419,88 +618,39 @@ const GlossaryPageV1 = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGlossaryTermAssets = (data: GlossaryTerm, forceReset = false) => {
|
||||
if (data?.fullyQualifiedName || data?.name) {
|
||||
const tagName = data?.fullyQualifiedName || data?.name; // Incase fqn is not fetched yet.
|
||||
searchData(
|
||||
'',
|
||||
forceReset ? 1 : assetData.currPage,
|
||||
PAGE_SIZE,
|
||||
`(tags:"${tagName}")`,
|
||||
'',
|
||||
'',
|
||||
myDataSearchIndex
|
||||
).then((res: SearchResponse) => {
|
||||
const hits = res.data.hits.hits;
|
||||
if (hits.length > 0) {
|
||||
setAssetData((pre) => {
|
||||
const data = formatDataResponse(hits);
|
||||
const total = res.data.hits.total.value;
|
||||
|
||||
return forceReset
|
||||
? {
|
||||
data,
|
||||
total,
|
||||
currPage: 1,
|
||||
}
|
||||
: { ...pre, data, total };
|
||||
});
|
||||
} else {
|
||||
setAssetData((pre) => {
|
||||
const data = [] as GlossaryTermAssets['data'];
|
||||
const total = 0;
|
||||
|
||||
return forceReset
|
||||
? {
|
||||
data,
|
||||
total,
|
||||
currPage: 1,
|
||||
}
|
||||
: { ...pre, data, total };
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setAssetData({ data: [], total: 0, currPage: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* handle assets page change
|
||||
* @param page new page number
|
||||
*/
|
||||
const handleAssetPagination = (page: number) => {
|
||||
setAssetData((pre) => ({ ...pre, currPage: page }));
|
||||
};
|
||||
|
||||
const handleSelectedData = (
|
||||
data: Glossary | GlossaryTerm,
|
||||
pos: string,
|
||||
key: string
|
||||
) => {
|
||||
handleChildLoading(true);
|
||||
const hierarchy = pos.split('-').splice(1);
|
||||
// console.log(hierarchy);
|
||||
if (hierarchy.length < 2) {
|
||||
setSelectedData(data);
|
||||
handleSelectedKey(key);
|
||||
setIsGlossaryActive(true);
|
||||
handleChildLoading(false);
|
||||
/**
|
||||
* handle route change on selecting glossary or glossary term
|
||||
* @param key fqn of glossary or Term
|
||||
*/
|
||||
const handleSelectedData = (key: string) => {
|
||||
const path = getGlossaryPath(key);
|
||||
history.push(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch details to show based on route params
|
||||
* and existing data list
|
||||
*/
|
||||
const fetchData = () => {
|
||||
if (glossariesList.length) {
|
||||
checkAndFetchDataByFQN(glossariesList, glossaryName);
|
||||
} else {
|
||||
setLoadingKey((pre) => {
|
||||
return !pre.includes(key) ? [...pre, key] : pre;
|
||||
});
|
||||
fetchGlossaryTermByName(
|
||||
(data as GlossaryTerm)?.fullyQualifiedName || data?.name,
|
||||
hierarchy,
|
||||
key
|
||||
);
|
||||
fetchGlossaryTermAssets(data as GlossaryTerm, true);
|
||||
fetchGlossaryList(glossaryName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchText = (text: string) => {
|
||||
setSearchText(text);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchGlossaryTermAssets(selectedData as GlossaryTerm);
|
||||
fetchGlossaryTermAssets(
|
||||
(selectedData as GlossaryTerm)?.fullyQualifiedName || ''
|
||||
);
|
||||
}, [assetData.currPage]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -508,8 +658,8 @@ const GlossaryPageV1 = () => {
|
||||
}, [searchText]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGlossaryList();
|
||||
}, []);
|
||||
fetchData();
|
||||
}, [glossaryName]);
|
||||
|
||||
return (
|
||||
<PageContainerV1 className="tw-pt-4">
|
||||
|
@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import {
|
||||
FormattedGlossarySuggestion,
|
||||
FormattedGlossaryTermData,
|
||||
@ -37,6 +38,10 @@ export interface GlossaryTermTreeNode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* To get all glossary terms
|
||||
* @returns promise of list of formatted glossary terms
|
||||
*/
|
||||
export const fetchGlossaryTerms = (): Promise<FormattedGlossaryTermData[]> => {
|
||||
return new Promise<FormattedGlossaryTermData[]>((resolve, reject) => {
|
||||
searchData(WILD_CARD_CHAR, 1, 1000, '', '', '', SearchIndex.GLOSSARY)
|
||||
@ -50,12 +55,22 @@ export const fetchGlossaryTerms = (): Promise<FormattedGlossaryTermData[]> => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To get list of fqns from list of glossary terms
|
||||
* @param terms formatted glossary terms
|
||||
* @returns list of term fqns
|
||||
*/
|
||||
export const getGlossaryTermlist = (
|
||||
terms: Array<FormattedGlossaryTermData> = []
|
||||
): Array<string> => {
|
||||
return terms.map((term: FormattedGlossaryTermData) => term?.fqdn);
|
||||
};
|
||||
|
||||
/**
|
||||
* To get child terms of any node if available
|
||||
* @param listTermFQN fqn of targeted child terms
|
||||
* @returns promise of list of glossary terms
|
||||
*/
|
||||
export const getChildGlossaryTerms = (
|
||||
listTermFQN: Array<string>
|
||||
): Promise<GlossaryTerm[]> => {
|
||||
@ -76,6 +91,11 @@ export const getChildGlossaryTerms = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* To recursively generate RcTree data from glossary list
|
||||
* @param data list of glossary or glossary terms
|
||||
* @returns RcTree data node
|
||||
*/
|
||||
export const generateTreeData = (data: ModifiedGlossaryData[]): DataNode[] => {
|
||||
return data.map((d) => {
|
||||
return d.children?.length
|
||||
@ -93,6 +113,13 @@ export const generateTreeData = (data: ModifiedGlossaryData[]): DataNode[] => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates glossary term tree node from fqn
|
||||
* and root node name
|
||||
* @param leafFqn node fqn
|
||||
* @param name root node name
|
||||
* @returns node for glossary tree
|
||||
*/
|
||||
const createGlossaryTermNode = (
|
||||
leafFqn: string,
|
||||
name: string
|
||||
@ -112,6 +139,12 @@ const createGlossaryTermNode = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* To merge the duplicate glossaries and terms
|
||||
* to generate optimised tree
|
||||
* @param treeNodes list of glossary nodes with duplicate items
|
||||
* @returns list of glossary nodes with unique items
|
||||
*/
|
||||
const optimiseGlossaryTermTree = (treeNodes?: GlossaryTermTreeNode[]) => {
|
||||
if (treeNodes) {
|
||||
for (let i = 0; i < treeNodes.length; i++) {
|
||||
@ -139,6 +172,11 @@ const optimiseGlossaryTermTree = (treeNodes?: GlossaryTermTreeNode[]) => {
|
||||
return treeNodes;
|
||||
};
|
||||
|
||||
/**
|
||||
* To generate glossry tree from searched terms
|
||||
* @param searchedTerms list of formatted searched terms
|
||||
* @returns list of glossary tree
|
||||
*/
|
||||
export const getSearchedGlossaryTermTree = (
|
||||
searchedTerms: FormattedGlossarySuggestion[]
|
||||
): GlossaryTermTreeNode[] => {
|
||||
@ -153,6 +191,12 @@ export const getSearchedGlossaryTermTree = (
|
||||
return termTree;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get Tree of glossaries based on search result
|
||||
* @param glossaries list of glossaries
|
||||
* @param searchedTerms list of formatted searched terms
|
||||
* @returns glossary list based on searched terms
|
||||
*/
|
||||
export const updateGlossaryListBySearchedTerms = (
|
||||
glossaries: ModifiedGlossaryData[],
|
||||
searchedTerms: FormattedGlossarySuggestion[]
|
||||
@ -171,6 +215,10 @@ export const updateGlossaryListBySearchedTerms = (
|
||||
}, [] as ModifiedGlossaryData[]);
|
||||
};
|
||||
|
||||
/**
|
||||
* To get actions for action dropdown button
|
||||
* @returns list of action items
|
||||
*/
|
||||
export const getActionsList = () => {
|
||||
return [
|
||||
{
|
||||
@ -180,6 +228,12 @@ export const getActionsList = () => {
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* To get hierarchy of fqns from glossary to targeted term
|
||||
* from given fqn
|
||||
* @param fqn fqn of glossary or glossary term
|
||||
* @returns list of fqns
|
||||
*/
|
||||
export const getHierarchicalKeysByFQN = (fqn: string) => {
|
||||
const keys = fqn.split('.').reduce((prev, curr) => {
|
||||
const currFqn = prev.length ? `${prev[prev.length - 1]}.${curr}` : curr;
|
||||
@ -190,12 +244,80 @@ export const getHierarchicalKeysByFQN = (fqn: string) => {
|
||||
return keys;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get glossary term data from glossary object
|
||||
* @param glossary parent glossary
|
||||
* @param termFqn fqn of targeted glossary term
|
||||
* @returns Glossary term or {}
|
||||
*/
|
||||
export const getTermDataFromGlossary = (
|
||||
glossary: ModifiedGlossaryData,
|
||||
termFqn: string
|
||||
) => {
|
||||
let data: ModifiedGlossaryData | GlossaryTerm = cloneDeep(glossary);
|
||||
const arrFQN = getHierarchicalKeysByFQN(termFqn);
|
||||
for (let i = 1; i < arrFQN.length; i++) {
|
||||
data = data?.children
|
||||
? ((data.children as unknown as GlossaryTerm[])?.find(
|
||||
(item) =>
|
||||
item.fullyQualifiedName === arrFQN[i] || item.name === arrFQN[i]
|
||||
) as GlossaryTerm)
|
||||
: ({} as GlossaryTerm);
|
||||
if (isEmpty(data)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* To get relative indexed position of
|
||||
* glossary term from tree of glossaries
|
||||
* @param arrGlossary list of glossary
|
||||
* @param termFqn fqn of target glossary term
|
||||
* @returns array of numbered positions
|
||||
*/
|
||||
export const getTermPosFromGlossaries = (
|
||||
arrGlossary: ModifiedGlossaryData[],
|
||||
termFqn: string
|
||||
) => {
|
||||
const arrFQN = getHierarchicalKeysByFQN(termFqn);
|
||||
const glossaryIdx = arrGlossary.findIndex((item) => item.name === arrFQN[0]);
|
||||
const pos = [];
|
||||
if (glossaryIdx !== -1) {
|
||||
pos.push(glossaryIdx);
|
||||
let data: ModifiedGlossaryData | GlossaryTerm = arrGlossary[glossaryIdx];
|
||||
for (let i = 1; i < arrFQN.length; i++) {
|
||||
const index = data?.children
|
||||
? (data.children as unknown as GlossaryTerm[])?.findIndex(
|
||||
(item) =>
|
||||
item.fullyQualifiedName === arrFQN[i] || item.name === arrFQN[i]
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
data = (data?.children ? data?.children[index] : {}) as GlossaryTerm;
|
||||
pos.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return pos;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches and adds root terms to each glossary
|
||||
* @param glossaries list of glossaries
|
||||
* @returns promise of list of glossaries with root terms
|
||||
*/
|
||||
const getRootTermEmbeddedGlossary = (
|
||||
glossaries: Array<ModifiedGlossaryData>
|
||||
): Promise<Array<ModifiedGlossaryData>> => {
|
||||
return new Promise<Array<ModifiedGlossaryData>>((resolve, reject) => {
|
||||
const promises = glossaries.map((glossary) =>
|
||||
getGlossaryTerms(glossary.id, 100, [
|
||||
getGlossaryTerms(glossary.id, 1000, [
|
||||
'children',
|
||||
'relatedTerms',
|
||||
'reviewers',
|
||||
@ -222,6 +344,13 @@ const getRootTermEmbeddedGlossary = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches list of glossaries with root terms in each of them
|
||||
* @param paging pagination cursor
|
||||
* @param limit result count
|
||||
* @param arrQueryFields api query-string
|
||||
* @returns promise of api response
|
||||
*/
|
||||
export const getGlossariesWithRootTerms = (
|
||||
paging = '',
|
||||
limit = 10,
|
||||
|
Loading…
x
Reference in New Issue
Block a user