diff --git a/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.spec.tsx b/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.spec.tsx new file mode 100644 index 00000000..0d24c0ab --- /dev/null +++ b/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved +} from '@testing-library/react'; + +import SHOGunAPIClient from '@terrestris/shogun-util/dist/service/SHOGunAPIClient'; + +import { CreateAllGroupsButton } from './CreateAllGroupsButton'; +import GroupService from '@terrestris/shogun-util/dist/service/GroupService'; +import Group from '@terrestris/shogun-util/dist/model/Group'; + +import type { PartialOmit } from '../../test-util'; + +const mockService: Partial> = { + createAllFromProvider: jest.fn() +}; + +const mockSHOGunAPIClient: PartialOmit = { + group: jest.fn().mockReturnValue(mockService) +}; + +jest.mock('../../Hooks/useSHOGunAPIClient', () => { + const originalModule = jest.requireActual('../../Hooks/useSHOGunAPIClient'); + return { + __esModule: true, + ...originalModule, + default: jest.fn(() => mockSHOGunAPIClient) + }; +}); + +describe('', () => { + + afterEach(cleanup); + + it('can be rendered', () => { + const { + container + } = render( + ); + + expect(container).toBeVisible(); + }); + + it('calls the appropriate service method', async () => { + render(); + + const buttonElement = screen.getByText('CreateAllGroupsButton.title'); + + fireEvent.click(buttonElement); + + expect(mockSHOGunAPIClient.group().createAllFromProvider).toHaveBeenCalled(); + + await waitForElementToBeRemoved(() => screen.queryByLabelText('loading')); + + expect(screen.getByText('CreateAllGroupsButton.success')).toBeVisible(); + }); +}); diff --git a/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.tsx b/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.tsx new file mode 100644 index 00000000..ff91fbe5 --- /dev/null +++ b/src/Component/CreateAllGroupsButton/CreateAllGroupsButton.tsx @@ -0,0 +1,81 @@ +import React, { + useState +} from 'react'; + +import { + UsergroupAddOutlined +} from '@ant-design/icons'; +import { + Button, + message, + Tooltip +} from 'antd'; +import { ButtonProps } from 'antd/lib/button'; + +import { useTranslation } from 'react-i18next'; + +import useSHOGunAPIClient from '../../Hooks/useSHOGunAPIClient'; + +import Logger from '../../Logger'; + +export type CreateAllGroupsButtonProps = Omit & { + onSuccess?: () => void; + onError?: (error: any) => void; +}; + +export const CreateAllGroupsButton: React.FC = ({ + onSuccess, + onError, + ...passThroughProps +}) => { + + const [isLoading, setIsLoading] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const client = useSHOGunAPIClient(); + + const { + t + } = useTranslation(); + + const onCreateGroupsClick = async () => { + setIsLoading(true); + + try { + await client?.group().createAllFromProvider(); + + messageApi.success(t('CreateAllGroupsButton.success')); + + onSuccess?.(); + } catch (error) { + messageApi.error(t('CreateAllGroupsButton.error')); + + Logger.error('Error while creating the groups: ', error); + + onError?.(error); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + {contextHolder} + + + + + ); +}; + +export default CreateAllGroupsButton; diff --git a/src/Component/CreateAllRolesButton/CreateAllRolesButton.spec.tsx b/src/Component/CreateAllRolesButton/CreateAllRolesButton.spec.tsx new file mode 100644 index 00000000..b266c152 --- /dev/null +++ b/src/Component/CreateAllRolesButton/CreateAllRolesButton.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved +} from '@testing-library/react'; + +import SHOGunAPIClient from '@terrestris/shogun-util/dist/service/SHOGunAPIClient'; + +import { CreateAllRolesButton } from './CreateAllRolesButton'; +import RoleService from '@terrestris/shogun-util/dist/service/RoleService'; +import Role from '@terrestris/shogun-util/dist/model/Role'; + +import type { PartialOmit } from '../../test-util'; + +const mockService: Partial> = { + createAllFromProvider: jest.fn() +}; + +const mockSHOGunAPIClient: PartialOmit = { + role: jest.fn().mockReturnValue(mockService) +}; + +jest.mock('../../Hooks/useSHOGunAPIClient', () => { + const originalModule = jest.requireActual('../../Hooks/useSHOGunAPIClient'); + return { + __esModule: true, + ...originalModule, + default: jest.fn(() => mockSHOGunAPIClient) + }; +}); + +describe('', () => { + + afterEach(cleanup); + + it('can be rendered', () => { + const { + container + } = render( + ); + + expect(container).toBeVisible(); + }); + + it('calls the appropriate service method', async () => { + render(); + + const buttonElement = screen.getByText('CreateAllRolesButton.title'); + + fireEvent.click(buttonElement); + + expect(mockSHOGunAPIClient.role().createAllFromProvider).toHaveBeenCalled(); + + await waitForElementToBeRemoved(() => screen.queryByLabelText('loading')); + + expect(screen.getByText('CreateAllRolesButton.success')).toBeVisible(); + }); +}); diff --git a/src/Component/CreateAllRolesButton/CreateAllRolesButton.tsx b/src/Component/CreateAllRolesButton/CreateAllRolesButton.tsx new file mode 100644 index 00000000..fb5cf4ce --- /dev/null +++ b/src/Component/CreateAllRolesButton/CreateAllRolesButton.tsx @@ -0,0 +1,81 @@ +import React, { + useState +} from 'react'; + +import { + TagOutlined +} from '@ant-design/icons'; +import { + Button, + message, + Tooltip +} from 'antd'; +import { ButtonProps } from 'antd/lib/button'; + +import { useTranslation } from 'react-i18next'; + +import useSHOGunAPIClient from '../../Hooks/useSHOGunAPIClient'; + +import Logger from '../../Logger'; + +export type CreateAllRolesButtonProps = Omit & { + onSuccess?: () => void; + onError?: (error: any) => void; +}; + +export const CreateAllRolesButton: React.FC = ({ + onSuccess, + onError, + ...passThroughProps +}) => { + + const [isLoading, setIsLoading] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const client = useSHOGunAPIClient(); + + const { + t + } = useTranslation(); + + const onCreateRolesClick = async () => { + setIsLoading(true); + + try { + await client?.role().createAllFromProvider(); + + messageApi.success(t('CreateAllRolesButton.success')); + + onSuccess?.(); + } catch (error) { + messageApi.error(t('CreateAllRolesButton.error')); + + Logger.error('Error while creating the roles: ', error); + + onError?.(error); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + {contextHolder} + + + + + ); +}; + +export default CreateAllRolesButton; diff --git a/src/Component/CreateAllUsersButton/CreateAllUsersButton.spec.tsx b/src/Component/CreateAllUsersButton/CreateAllUsersButton.spec.tsx new file mode 100644 index 00000000..bcb389bb --- /dev/null +++ b/src/Component/CreateAllUsersButton/CreateAllUsersButton.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved +} from '@testing-library/react'; + +import SHOGunAPIClient from '@terrestris/shogun-util/dist/service/SHOGunAPIClient'; + +import { CreateAllUsersButton } from './CreateAllUsersButton'; +import UserService from '@terrestris/shogun-util/dist/service/UserService'; +import User from '@terrestris/shogun-util/dist/model/User'; + +import type { PartialOmit } from '../../test-util'; + +const mockService: Partial> = { + createAllFromProvider: jest.fn() +}; + +const mockSHOGunAPIClient: PartialOmit = { + user: jest.fn().mockReturnValue(mockService) +}; + +jest.mock('../../Hooks/useSHOGunAPIClient', () => { + const originalModule = jest.requireActual('../../Hooks/useSHOGunAPIClient'); + return { + __esModule: true, + ...originalModule, + default: jest.fn(() => mockSHOGunAPIClient) + }; +}); + +describe('', () => { + + afterEach(cleanup); + + it('can be rendered', () => { + const { + container + } = render( + ); + + expect(container).toBeVisible(); + }); + + it('calls the appropriate service method', async () => { + render(); + + const buttonElement = screen.getByText('CreateAllUsersButton.title'); + + fireEvent.click(buttonElement); + + expect(mockSHOGunAPIClient.user().createAllFromProvider).toHaveBeenCalled(); + + await waitForElementToBeRemoved(() => screen.queryByLabelText('loading')); + + expect(screen.getByText('CreateAllUsersButton.success')).toBeVisible(); + }); +}); diff --git a/src/Component/CreateAllUsersButton/CreateAllUsersButton.tsx b/src/Component/CreateAllUsersButton/CreateAllUsersButton.tsx new file mode 100644 index 00000000..57b7c942 --- /dev/null +++ b/src/Component/CreateAllUsersButton/CreateAllUsersButton.tsx @@ -0,0 +1,81 @@ +import React, { + useState +} from 'react'; + +import { + UserAddOutlined +} from '@ant-design/icons'; +import { + Button, + message, + Tooltip +} from 'antd'; +import { ButtonProps } from 'antd/lib/button'; + +import { useTranslation } from 'react-i18next'; + +import useSHOGunAPIClient from '../../Hooks/useSHOGunAPIClient'; + +import Logger from '../../Logger'; + +export type CreateAllUsersButtonProps = Omit & { + onSuccess?: () => void; + onError?: (error: any) => void; +}; + +export const CreateAllUsersButton: React.FC = ({ + onSuccess, + onError, + ...passThroughProps +}) => { + + const [isLoading, setIsLoading] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const client = useSHOGunAPIClient(); + + const { + t + } = useTranslation(); + + const onCreateUsersClick = async () => { + setIsLoading(true); + + try { + await client?.user().createAllFromProvider(); + + messageApi.success(t('CreateAllUsersButton.success')); + + onSuccess?.(); + } catch (error) { + messageApi.success(t('CreateAllUsersButton.error')); + + Logger.error('Error while creating the users: ', error); + + onError?.(error); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + {contextHolder} + + + + + ); +}; + +export default CreateAllUsersButton; diff --git a/src/Component/GeneralEntity/GeneralEntityRoot/GeneralEntityRoot.tsx b/src/Component/GeneralEntity/GeneralEntityRoot/GeneralEntityRoot.tsx index b1f860b3..e141655d 100644 --- a/src/Component/GeneralEntity/GeneralEntityRoot/GeneralEntityRoot.tsx +++ b/src/Component/GeneralEntity/GeneralEntityRoot/GeneralEntityRoot.tsx @@ -1,5 +1,3 @@ -import './GeneralEntityRoot.less'; - import React, { useCallback, useEffect, @@ -10,8 +8,7 @@ import React, { import { FormOutlined, SaveOutlined, - UndoOutlined, - UploadOutlined + UndoOutlined } from '@ant-design/icons'; import { PageHeader } from '@ant-design/pro-layout'; @@ -19,8 +16,7 @@ import { Button, Form, notification, - Modal, - Upload + Modal } from 'antd'; import { TableProps, @@ -28,42 +24,31 @@ import { import { SortOrder } from 'antd/es/table/interface'; -import { - RcFile, - UploadChangeParam -} from 'antd/lib/upload'; -import { - UploadFile -} from 'antd/lib/upload/interface'; + import i18next from 'i18next'; import _isEmpty from 'lodash/isEmpty'; import _isNil from 'lodash/isNil'; import { NamePath } from 'rc-field-form/lib/interface'; -import { - UploadRequestOption -} from 'rc-upload/lib/interface'; + import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; -import {Link, +import { + Link, matchPath, useLocation, - useNavigate} from 'react-router-dom'; -import { - Shapefile -} from 'shapefile.js'; + useNavigate +} from 'react-router-dom'; + import config from 'shogunApplicationConfig'; import Logger from '@terrestris/base-util/dist/Logger'; import BaseEntity from '@terrestris/shogun-util/dist/model/BaseEntity'; -import { - getBearerTokenHeader -} from '@terrestris/shogun-util/dist/security/getBearerTokenHeader'; import { PageOpts } from '@terrestris/shogun-util/dist/service/GenericService'; @@ -82,31 +67,9 @@ import GeneralEntityForm, { import GeneralEntityTable, { TableConfig } from '../GeneralEntityTable/GeneralEntityTable'; +import { GeneralEntityRootProvider } from '../../../Context/GeneralEntityRootContext'; -type LayerUploadOptions = { - baseUrl: string; - workspace: string; - storeName: string; - layerName: string; - file: RcFile; -}; - -type LayerUploadResponse = { - layerName: string; - workspace: string; - baseUrl: string; -}; - -type FeatureTypeAttributes = { - attribute: { - name: string; - minOccurs: number; - maxOccurs: number; - nillable: boolean; - binding?: string; - length?: number; - }[]; -}; +import './GeneralEntityRoot.less'; export type GeneralEntityConfigType = { i18n: FormTranslations; @@ -122,7 +85,22 @@ export type GeneralEntityConfigType = { defaultEntity?: T; }; -type OwnProps = GeneralEntityConfigType; +export type ToolbarSlotProps = { + onSuccess?: () => void; + onError?: () => void; +} & Record; + +type OwnProps = GeneralEntityConfigType & { + /** + * Slots for additional components that are dependent on the entity type. + */ + slots?: { + /** + * Slot for left toolbar (above the table). + */ + leftToolbar?: React.ReactNode; + }; +}; export type GeneralEntityRootProps = OwnProps & React.HTMLAttributes; @@ -135,9 +113,10 @@ export function GeneralEntityRoot({ navigationTitle = 'Entitäten', subTitle = '… mit denen man Dinge tun kann (aus Gründen bspw.)', formConfig, - tableConfig = {}, + tableConfig, defaultEntity, - onEntitiesLoaded = () => { } + onEntitiesLoaded, + slots }: GeneralEntityRootProps) { const location = useLocation(); @@ -156,7 +135,6 @@ export function GeneralEntityRoot({ const [isGridLoading, setGridLoading] = useState(false); const [isFormLoading, setFormLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [isUploadingFile, setIsUploadingFile] = useState(false); const [pageTotal, setPageTotal] = useState(); const [pageSize, setPageSize] = useState(config.defaultPageSize || 10); const [pageCurrent, setPageCurrent] = useState(1); @@ -245,7 +223,7 @@ export function GeneralEntityRoot({ const allEntries = await entityController?.findAll(pageOpts); setPageTotal(allEntries.totalElements); setEntities(allEntries.content || []); - onEntitiesLoaded(allEntries.content, entityType); + onEntitiesLoaded?.(allEntries.content, entityType); } catch (error) { Logger.error(error); } finally { @@ -429,293 +407,6 @@ export function GeneralEntityRoot({ } }; - const onBeforeFileUpload = (file: RcFile) => { - const maxSize = config.geoserver?.upload?.limit || '200000000'; - const fileType = file.type; - const fileSize = file.size; - - // 1. Check file size - if (fileSize > maxSize) { - notification.error({ - message: t('GeneralEntityRoot.upload.error.message', { - entity: TranslationUtil.getTranslationFromConfig(entityName, i18n) - }), - description: t('GeneralEntityRoot.upload.error.descriptionSize', { - maxSize: maxSize / 1000000 - }) - }); - - return false; - } - - // 2. Check file format - const supportedFormats = ['application/zip', 'application/x-zip-compressed', 'image/tiff']; - if (!supportedFormats.includes(fileType)) { - notification.error({ - message: t('GeneralEntityRoot.upload.error.message', { - entity: TranslationUtil.getTranslationFromConfig(entityName, i18n) - }), - description: t('GeneralEntityRoot.upload.error.descriptionFormat', { - supportedFormats: supportedFormats.join(', ') - }) - }); - - return false; - } - - return true; - }; - - const uploadGeoTiff = async (options: LayerUploadOptions): Promise => { - const { - baseUrl, - workspace, - storeName, - layerName, - file - } = options; - - const url = `${baseUrl}/rest/workspaces/${workspace}/coveragestores/` + - `${storeName}/file.geotiff?coverageName=${layerName}`; - - const response = await fetch(url, { - method: 'PUT', - headers: { - ...getBearerTokenHeader(client?.getKeycloak()), - 'Content-Type': 'image/tiff' - }, - body: file - }); - - if (!response.ok) { - throw new Error('No successful response while uploading the file'); - } - }; - - const uploadShapeZip = async (options: LayerUploadOptions): Promise => { - const { - baseUrl, - workspace, - storeName, - layerName, - file - } = options; - - const shp = await Shapefile.load(file); - - let featureTypeName = ''; - let featureTypeAttributes: FeatureTypeAttributes = { - attribute: [] - }; - - if (Object.entries(shp).length !== 1) { - throw new Error(t('GeneralEntityRoot.upload.error.descriptionZipContent')); - } - - Object.entries(shp).forEach(([k, v]) => { - featureTypeName = k; - - const dbfContent = v.parse('dbf', { - properties: false - }); - - featureTypeAttributes.attribute = dbfContent.fields.map(field => ({ - name: field.name, - minOccurs: 0, - maxOccurs: 1, - nillable: true, - binding: getAttributeType(field.type), - length: field.length - })); - - const shxContent = v.parse('shx'); - - featureTypeAttributes.attribute.push({ - name: 'the_geom', - minOccurs: 0, - maxOccurs: 1, - nillable: true, - binding: getGeometryType(shxContent.header.type) - }); - }); - - const url = `${baseUrl}/rest/workspaces/${workspace}/datastores/` + - `${storeName}/file.shp?configure=none`; - - const response = await fetch(url, { - method: 'PUT', - headers: { - ...getBearerTokenHeader(client?.getKeycloak()), - 'Content-Type': 'application/zip' - }, - body: file - }); - - if (!response.ok) { - throw new Error('No successful response while uploading the file'); - } - - const featureTypeUrl = `${baseUrl}/rest/workspaces/${workspace}/datastores/${storeName}/featuretypes`; - - const featureTypeResponse = await fetch(featureTypeUrl, { - method: 'POST', - headers: { - ...getBearerTokenHeader(client?.getKeycloak()), - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - featureType: { - enabled: true, - name: layerName, - nativeName: featureTypeName, - title: layerName, - attributes: featureTypeAttributes - } - }) - }); - - if (!featureTypeResponse.ok) { - throw new Error('No successful response while creating the featuretype'); - } - }; - - const getGeometryType = (geometryTypeNumber: number): string | undefined => { - const allTypes: { - [key: number]: string | undefined; - } = { - 0: undefined, // Null - 1: 'org.locationtech.jts.geom.Point', // Point - 3: 'org.locationtech.jts.geom.LineString', // Polyline - 5: 'org.locationtech.jts.geom.Polygon', // Polygon - 8: 'org.locationtech.jts.geom.MultiPoint', // MultiPoint - 11: 'org.locationtech.jts.geom.Point', // PointZ - 13: 'org.locationtech.jts.geom.LineString', // PolylineZ - 15: 'org.locationtech.jts.geom.Polygon', // PolygonZ - 18: 'org.locationtech.jts.geom.MultiPoint', // MultiPointZ - 21: 'org.locationtech.jts.geom.Point', // PointM - 23: 'org.locationtech.jts.geom.LineString', // PolylineM - 25: 'org.locationtech.jts.geom.Polygon', // PolygonM - 28: 'org.locationtech.jts.geom.MultiPoint', // MultiPointM - 31: undefined // MultiPatch - }; - - return allTypes[geometryTypeNumber]; - }; - - const getAttributeType = (dbfFieldType: string) => { - switch (dbfFieldType) { - case 'C': // Character - return 'java.lang.String'; - case 'D': // Date - return 'java.util.Date'; - case 'N': // Numeric - return 'java.lang.Long'; - case 'F': // Floating point - return 'java.lang.Double'; - case 'L': // Logical - return 'java.lang.Boolean'; - case 'M': // Memo - return undefined; - default: - return undefined; - } - }; - - const onFileUploadAction = async (options: UploadRequestOption) => { - const { - onError = () => undefined, - onSuccess = () => undefined, - file - } = options; - - const splittedFileName = (file as RcFile).name.split('.'); - const fileType = (file as RcFile).type; - const geoServerBaseUrl = config.geoserver?.base || '/geoserver'; - const workspace = config.geoserver?.upload?.workspace || 'SHOGUN'; - const layerName = `${splittedFileName[0]}_${Date.now()}`.toUpperCase(); - - const uploadData = { - file: file as RcFile, - baseUrl: geoServerBaseUrl, - workspace: workspace, - storeName: layerName, - layerName: layerName - }; - - try { - if (fileType === 'image/tiff') { - await uploadGeoTiff(uploadData); - } - - if (fileType === 'application/zip' || fileType === 'application/x-zip-compressed') { - await uploadShapeZip(uploadData); - } - - onSuccess({ - baseUrl: geoServerBaseUrl, - workspace: workspace, - layerName: layerName - }); - } catch (error) { - onError({ - name: 'UploadError', - message: (error as Error)?.message - }); - } - }; - - const onFileUploadChange = async (info: UploadChangeParam>) => { - const file = info.file; - - if (file.status === 'uploading') { - setIsUploadingFile(true); - } - - if (file.status === 'done') { - await client?.layer().add({ - name: file.response?.layerName ?? 'LAYER-DEFAULT-NAME', - type: 'TILEWMS', - clientConfig: { - hoverable: false - }, - sourceConfig: { - url: `${file.response?.baseUrl}/ows?`, - layerNames: `${file.response?.workspace}:${file.response?.layerName}`, - useBearerToken: true - } - }); - - // Refresh the list - await fetchEntities(); - - // Finally, show success message - setIsUploadingFile(false); - - notification.success({ - message: t('GeneralEntityRoot.upload.success.message', { - entity: TranslationUtil.getTranslationFromConfig(entityName, i18n) - }), - description: t('GeneralEntityRoot.upload.success.description', { - fileName: file.fileName, - layerName: file.response?.layerName - }), - }); - } else if (file.status === 'error') { - setIsUploadingFile(false); - - Logger.error(file.error); - - notification.error({ - message: t('GeneralEntityRoot.upload.error.message', { - entity: TranslationUtil.getTranslationFromConfig(entityName, i18n) - }), - description: t('GeneralEntityRoot.upload.error.description', { - fileName: file.fileName - }) - }); - } - }; - /** * Shortcut: Save entity form when ctrl+s is pressed. */ @@ -770,119 +461,112 @@ export function GeneralEntityRoot({ }; return ( -
- navigate(-1)} - title={TranslationUtil.getTranslationFromConfig(navigationTitle, i18n)} - subTitle={subTitle} - extra={[ - , - - ]} + +
- -
-
- + navigate(-1)} + title={TranslationUtil.getTranslationFromConfig(navigationTitle, i18n)} + subTitle={subTitle} + extra={[ , + - - {/* Upload only available for layer entities */} - {entityType === 'layer' && ( - + +
+
+ - - )} + + {slots?.leftToolbar && ( +
+ { slots.leftToolbar } +
+ )} +
+
- -
-
- { - id && !_isNil(entityId) && ( - - ) - } +
+ { + id && !_isNil(entityId) && ( + + ) + } +
+
{contextHolder}
-
{contextHolder}
-
+ ); } diff --git a/src/Component/GeneralEntity/GeneralEntityTable/GeneralEntityTable.tsx b/src/Component/GeneralEntity/GeneralEntityTable/GeneralEntityTable.tsx index a71257d4..2cf2f64d 100644 --- a/src/Component/GeneralEntity/GeneralEntityTable/GeneralEntityTable.tsx +++ b/src/Component/GeneralEntity/GeneralEntityTable/GeneralEntityTable.tsx @@ -1,6 +1,6 @@ import './GeneralEntityTable.less'; -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; import { DeleteOutlined, SyncOutlined } from '@ant-design/icons'; @@ -26,6 +26,7 @@ import LinkField from '../../FormField/LinkField/LinkField'; import VerifyProviderDetailsField from '../../FormField/VerifyProviderDetailsField/VerifyProviderDetailsField'; import LayerPreview from '../../LayerPreview/LayerPreview'; +import GeneralEntityRootContext from '../../../Context/GeneralEntityRootContext'; export type TableConfig = { columnDefinition?: GeneralEntityTableColumn[]; @@ -90,6 +91,8 @@ export function GeneralEntityTable({ const { t } = useTranslation(); const onRowClick = (record: T) => navigate(`${routePath}/${record.id}`); + const generalEntityRootContext = useContext(GeneralEntityRootContext); + const onDeleteClick = async (record: T) => { const entityId = record?.id; @@ -318,7 +321,7 @@ export function GeneralEntityTable({ triggerAsc: t('Table.triggerAsc'), cancelSort: t('Table.cancelSort') }} - dataSource={entities} + dataSource={generalEntityRootContext?.entities} onRow={(record) => { return { onClick: () => onRowClick(record) diff --git a/src/Component/GeneralEntity/Slots/ToolbarCreateAllGroupsButton/ToolbarCreateAllGroupsButton.tsx b/src/Component/GeneralEntity/Slots/ToolbarCreateAllGroupsButton/ToolbarCreateAllGroupsButton.tsx new file mode 100644 index 00000000..12a8d054 --- /dev/null +++ b/src/Component/GeneralEntity/Slots/ToolbarCreateAllGroupsButton/ToolbarCreateAllGroupsButton.tsx @@ -0,0 +1,25 @@ +import { + useContext +} from 'react'; + +import CreateAllGroupsButton, { + CreateAllGroupsButtonProps +} from '../../../CreateAllGroupsButton/CreateAllGroupsButton'; + +import GeneralEntityRootContext from '../../../../Context/GeneralEntityRootContext'; + +export const ToolbarCreateAllGroupsButton: React.FC = () => { + const generalEntityRootContext = useContext(GeneralEntityRootContext); + + const onSuccess = () => { + generalEntityRootContext?.fetchEntities?.(); + }; + + return ( + + ); +}; + +export default ToolbarCreateAllGroupsButton; diff --git a/src/Component/GeneralEntity/Slots/ToolbarCreateAllRolesButton/ToolbarCreateAllRolesButton.tsx b/src/Component/GeneralEntity/Slots/ToolbarCreateAllRolesButton/ToolbarCreateAllRolesButton.tsx new file mode 100644 index 00000000..cd63daac --- /dev/null +++ b/src/Component/GeneralEntity/Slots/ToolbarCreateAllRolesButton/ToolbarCreateAllRolesButton.tsx @@ -0,0 +1,25 @@ +import { + useContext +} from 'react'; + +import CreateAllRolesButton, { + CreateAllRolesButtonProps +} from '../../../CreateAllRolesButton/CreateAllRolesButton'; + +import GeneralEntityRootContext from '../../../../Context/GeneralEntityRootContext'; + +export const ToolbarCreateAllRolesButton: React.FC = () => { + const generalEntityRootContext = useContext(GeneralEntityRootContext); + + const onSuccess = () => { + generalEntityRootContext?.fetchEntities?.(); + }; + + return ( + + ); +}; + +export default ToolbarCreateAllRolesButton; diff --git a/src/Component/GeneralEntity/Slots/ToolbarCreateAllUsersButton/ToolbarCreateAllUsersButton.tsx b/src/Component/GeneralEntity/Slots/ToolbarCreateAllUsersButton/ToolbarCreateAllUsersButton.tsx new file mode 100644 index 00000000..10cc3644 --- /dev/null +++ b/src/Component/GeneralEntity/Slots/ToolbarCreateAllUsersButton/ToolbarCreateAllUsersButton.tsx @@ -0,0 +1,25 @@ +import { + useContext +} from 'react'; + +import CreateAllUsersButton, { + CreateAllUsersButtonProps +} from '../../../CreateAllUsersButton/CreateAllUsersButton'; + +import GeneralEntityRootContext from '../../../../Context/GeneralEntityRootContext'; + +export const ToolbarCreateAllUsersButton: React.FC = () => { + const generalEntityRootContext = useContext(GeneralEntityRootContext); + + const onSuccess = () => { + generalEntityRootContext?.fetchEntities?.(); + }; + + return ( + + ); +}; + +export default ToolbarCreateAllUsersButton; diff --git a/src/Component/GeneralEntity/Slots/ToolbarUploadLayerButton/ToolbarUploadLayerButton.tsx b/src/Component/GeneralEntity/Slots/ToolbarUploadLayerButton/ToolbarUploadLayerButton.tsx new file mode 100644 index 00000000..35d97291 --- /dev/null +++ b/src/Component/GeneralEntity/Slots/ToolbarUploadLayerButton/ToolbarUploadLayerButton.tsx @@ -0,0 +1,25 @@ +import { + useContext +} from 'react'; + +import UploadLayerButton, { + UploadLayerButtonProps +} from '../../../UploadLayerButton/UploadLayerButton'; + +import GeneralEntityRootContext from '../../../../Context/GeneralEntityRootContext'; + +export const ToolbarUploadLayerButton: React.FC = () => { + const generalEntityRootContext = useContext(GeneralEntityRootContext); + + const onSuccess = () => { + generalEntityRootContext?.fetchEntities?.(); + }; + + return ( + + ); +}; + +export default ToolbarUploadLayerButton; diff --git a/src/Component/Menu/Navigation/Navigation.spec.tsx b/src/Component/Menu/Navigation/Navigation.spec.tsx index 6439cf30..a7685d0d 100644 --- a/src/Component/Menu/Navigation/Navigation.spec.tsx +++ b/src/Component/Menu/Navigation/Navigation.spec.tsx @@ -7,7 +7,11 @@ import { waitFor, } from '@testing-library/react'; -import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom'; +import { + MemoryRouter, + useLocation, + useNavigate +} from 'react-router-dom'; import { Navigation } from './Navigation'; @@ -81,8 +85,6 @@ describe('', () => { expect(screen.getByText('Global')).toBeInTheDocument(); expect(screen.getByText('Logging levels')).toBeInTheDocument(); }); - - }); it('opens submenu when an item is selected', async () => { @@ -132,4 +134,88 @@ describe('', () => { expect(container.querySelector('.ant-menu-inline-collapsed')).toBeInTheDocument(); }); + it('renders default and custom entries in the navigation bar', async () => { + const defaultEntry = { + entityType: 'application', + entityName: '#i18n.entityName', + endpoint: '/applications', + navigationTitle: "#i18n.navigationTitle", + formConfig: { + name: 'application', + fields: [{ + dataField: 'name', + readOnly: true + }] + }, + i18n: { + de: { + entityName: 'Applikation', + navigationTitle: 'Applikationen', + titleName: 'Name' + }, + en: { + entityName: 'Application', + navigationTitle: 'Applications', + titleName: 'Name' + } + }, + tableConfig: { + columnDefinition: [{ + title: '#i18n.titleName', + dataIndex: 'name', + key: 'name' + }] + } + }; + + const customEntry = { + entityType: 'myEntity', + entityName: '#i18n.entityName', + endpoint: '/myEntities', + navigationTitle: "#i18n.navigationTitle", + formConfig: { + name: 'myEntity', + fields: [{ + dataField: 'name', + readOnly: true + }] + }, + i18n: { + de: { + entityName: 'MyEntity', + navigationTitle: 'MyEntities', + titleName: 'Name' + }, + en: { + entityName: 'MyEntity', + navigationTitle: 'MyEntities', + titleName: 'Name' + } + }, + tableConfig: { + columnDefinition: [{ + title: '#i18n.titleName', + dataIndex: 'name', + key: 'name' + }] + } + }; + + render( + + + + ); + + const applicationEntry = await waitFor(() => screen.getByText('Applications')); + const myEntityEntry = await waitFor(() => screen.getByText('MyEntities')); + + expect(applicationEntry).toBeInTheDocument(); + expect(myEntityEntry).toBeInTheDocument(); + }); }); diff --git a/src/Component/Menu/Navigation/Navigation.tsx b/src/Component/Menu/Navigation/Navigation.tsx index 1891eb65..0d40f2b4 100644 --- a/src/Component/Menu/Navigation/Navigation.tsx +++ b/src/Component/Menu/Navigation/Navigation.tsx @@ -8,7 +8,9 @@ import { AppstoreOutlined, UserOutlined, TeamOutlined, - FileTextOutlined + FileTextOutlined, + TagOutlined, + ApiOutlined } from '@ant-design/icons'; import { @@ -61,11 +63,22 @@ export const Navigation: React.FC = ({ const navigationContentChildren: ItemType[] = []; if (entityConfigs && entityConfigs.length > 0) { - + // The following entities are the "default" SHOGun ones. const applicationConfig = entityConfigs.find(e => e.entityType === 'application'); const layersConfig = entityConfigs.find(e => e.entityType === 'layer'); const userConfig = entityConfigs.find(e => e.entityType === 'user'); const groupsConfig = entityConfigs.find(e => e.entityType === 'group'); + const rolesConfig = entityConfigs.find(e => e.entityType === 'role'); + + // But it's also possible to add custom entities to the navigation. + const otherConfigs = entityConfigs.filter(e => ![ + applicationConfig, + layersConfig, + userConfig, + groupsConfig, + rolesConfig + ].includes(e)); + if (applicationConfig) { navigationContentChildren.push({ key: 'application', @@ -118,6 +131,33 @@ export const Navigation: React.FC = ({ ) }); } + if (rolesConfig) { + navigationContentChildren.push({ + key: 'role', + label: ( + <> + + + {TranslationUtil.getTranslationFromConfig(rolesConfig?.navigationTitle, rolesConfig?.i18n)} + + + ) + }); + } + otherConfigs.forEach(entityConfig => { + navigationContentChildren.push({ + key: entityConfig.entityType, + label: ( + <> + {/* TODO We might think about making the icon configurable */} + + + {TranslationUtil.getTranslationFromConfig(entityConfig?.navigationTitle, entityConfig?.i18n)} + + + ) + }); + }); } if (navigationConf?.general?.imagefiles?.visible) { diff --git a/src/Component/UploadLayerButton/UploadLayerButton.spec.tsx b/src/Component/UploadLayerButton/UploadLayerButton.spec.tsx new file mode 100644 index 00000000..6059dd0c --- /dev/null +++ b/src/Component/UploadLayerButton/UploadLayerButton.spec.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + cleanup, + fireEvent, + render, + screen, + waitForElementToBeRemoved +} from '@testing-library/react'; + +import SHOGunAPIClient from '@terrestris/shogun-util/dist/service/SHOGunAPIClient'; + +import { CreateAllGroupsButton } from './CreateAllGroupsButton'; +import GroupService from '@terrestris/shogun-util/dist/service/GroupService'; +import Group from '@terrestris/shogun-util/dist/model/Group'; + +import type { PartialOmit } from '../../test-util'; + +const mockService: Partial> = { + createFromProvider: jest.fn() +}; + +const mockSHOGunAPIClient: PartialOmit = { + group: jest.fn().mockReturnValue(mockService) +}; + +jest.mock('../../Hooks/useSHOGunAPIClient', () => { + const originalModule = jest.requireActual('../../Hooks/useSHOGunAPIClient'); + return { + __esModule: true, + ...originalModule, + default: jest.fn(() => mockSHOGunAPIClient) + }; +}); + +describe('', () => { + + afterEach(cleanup); + + it('can be rendered', () => { + const { + container + } = render( + ); + + expect(container).toBeVisible(); + }); + + it('calls the appropriate service method', async () => { + render(); + + const buttonElement = screen.getByText('CreateAllGroupsButton.title'); + + fireEvent.click(buttonElement); + + expect(mockSHOGunAPIClient.group().createFromProvider).toHaveBeenCalled(); + + await waitForElementToBeRemoved(() => screen.queryByLabelText('loading')); + + expect(screen.getByText('CreateAllGroupsButton.success')).toBeVisible(); + }); +}); diff --git a/src/Component/UploadLayerButton/UploadLayerButton.tsx b/src/Component/UploadLayerButton/UploadLayerButton.tsx new file mode 100644 index 00000000..4306a446 --- /dev/null +++ b/src/Component/UploadLayerButton/UploadLayerButton.tsx @@ -0,0 +1,392 @@ +import React, { + useState +} from 'react'; + +import { + UploadOutlined +} from '@ant-design/icons'; + +import { + notification, + Button, + Upload +} from 'antd'; +import { + RcFile, + UploadChangeParam +} from 'antd/lib/upload'; +import { + UploadFile +} from 'antd/lib/upload/interface'; +import { + UploadRequestOption +} from 'rc-upload/lib/interface'; +import { + Shapefile +} from 'shapefile.js'; + +import { ButtonProps } from 'antd/lib/button'; + +import { + getBearerTokenHeader +} from '@terrestris/shogun-util/dist/security/getBearerTokenHeader'; + +import config from 'shogunApplicationConfig'; + +import { useTranslation } from 'react-i18next'; + +import useSHOGunAPIClient from '../../Hooks/useSHOGunAPIClient'; + +import Logger from '../../Logger'; + +export type LayerUploadOptions = { + baseUrl: string; + workspace: string; + storeName: string; + layerName: string; + file: RcFile; +}; + +export type LayerUploadResponse = { + layerName: string; + workspace: string; + baseUrl: string; +}; + +export type FeatureTypeAttributes = { + attribute: { + name: string; + minOccurs: number; + maxOccurs: number; + nillable: boolean; + binding?: string; + length?: number; + }[]; +}; + +export type UploadLayerButtonProps = Omit & { + onSuccess?: (layerName?: LayerUploadResponse) => void; + onError?: (error: any) => void; +} + +export const UploadLayerButton: React.FC = ({ + onSuccess: onSuccessProp, + onError: onErrorProp, + ...passThroughProps +}) => { + + const [isUploadingFile, setIsUploadingFile] = useState(false); + + const client = useSHOGunAPIClient(); + const { + t + } = useTranslation(); + + const onBeforeFileUpload = (file: RcFile) => { + const maxSize = config.geoserver?.upload?.limit || '200000000'; + const fileType = file.type; + const fileSize = file.size; + + // 1. Check file size + if (fileSize > maxSize) { + notification.error({ + // TODO Move in translation file to own comp + message: t('UploadLayerButton.error.message'), + description: t('UploadLayerButton.error.descriptionSize', { + maxSize: maxSize / 1000000 + }) + }); + + return false; + } + + // 2. Check file format + const supportedFormats = ['application/zip', 'application/x-zip-compressed', 'image/tiff']; + if (!supportedFormats.includes(fileType)) { + notification.error({ + message: t('UploadLayerButton.error.message'), + description: t('UploadLayerButton.error.descriptionFormat', { + supportedFormats: supportedFormats.join(', ') + }) + }); + + return false; + } + + return true; + }; + + const uploadGeoTiff = async (options: LayerUploadOptions): Promise => { + const { + baseUrl, + workspace, + storeName, + layerName, + file + } = options; + + const url = `${baseUrl}/rest/workspaces/${workspace}/coveragestores/` + + `${storeName}/file.geotiff?coverageName=${layerName}`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + ...getBearerTokenHeader(client?.getKeycloak()), + 'Content-Type': 'image/tiff' + }, + body: file + }); + + if (!response.ok) { + throw new Error('No successful response while uploading the file'); + } + }; + + const uploadShapeZip = async (options: LayerUploadOptions): Promise => { + const { + baseUrl, + workspace, + storeName, + layerName, + file + } = options; + + const shp = await Shapefile.load(file); + + let featureTypeName = ''; + let featureTypeAttributes: FeatureTypeAttributes = { + attribute: [] + }; + + if (Object.entries(shp).length !== 1) { + throw new Error(t('UploadLayerButton.error.descriptionZipContent')); + } + + Object.entries(shp).forEach(([k, v]) => { + featureTypeName = k; + + const dbfContent = v.parse('dbf', { + properties: false + }); + + featureTypeAttributes.attribute = dbfContent.fields.map(field => ({ + name: field.name, + minOccurs: 0, + maxOccurs: 1, + nillable: true, + binding: getAttributeType(field.type), + length: field.length + })); + + const shxContent = v.parse('shx'); + + featureTypeAttributes.attribute.push({ + name: 'the_geom', + minOccurs: 0, + maxOccurs: 1, + nillable: true, + binding: getGeometryType(shxContent.header.type) + }); + }); + + const url = `${baseUrl}/rest/workspaces/${workspace}/datastores/` + + `${storeName}/file.shp?configure=none`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + ...getBearerTokenHeader(client?.getKeycloak()), + 'Content-Type': 'application/zip' + }, + body: file + }); + + if (!response.ok) { + throw new Error('No successful response while uploading the file'); + } + + const featureTypeUrl = `${baseUrl}/rest/workspaces/${workspace}/datastores/${storeName}/featuretypes`; + + const featureTypeResponse = await fetch(featureTypeUrl, { + method: 'POST', + headers: { + ...getBearerTokenHeader(client?.getKeycloak()), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + featureType: { + enabled: true, + name: layerName, + nativeName: featureTypeName, + title: layerName, + attributes: featureTypeAttributes + } + }) + }); + + if (!featureTypeResponse.ok) { + throw new Error('No successful response while creating the featuretype'); + } + }; + + const getGeometryType = (geometryTypeNumber: number): string | undefined => { + const allTypes: { + [key: number]: string | undefined; + } = { + 0: undefined, // Null + 1: 'org.locationtech.jts.geom.Point', // Point + 3: 'org.locationtech.jts.geom.LineString', // Polyline + 5: 'org.locationtech.jts.geom.Polygon', // Polygon + 8: 'org.locationtech.jts.geom.MultiPoint', // MultiPoint + 11: 'org.locationtech.jts.geom.Point', // PointZ + 13: 'org.locationtech.jts.geom.LineString', // PolylineZ + 15: 'org.locationtech.jts.geom.Polygon', // PolygonZ + 18: 'org.locationtech.jts.geom.MultiPoint', // MultiPointZ + 21: 'org.locationtech.jts.geom.Point', // PointM + 23: 'org.locationtech.jts.geom.LineString', // PolylineM + 25: 'org.locationtech.jts.geom.Polygon', // PolygonM + 28: 'org.locationtech.jts.geom.MultiPoint', // MultiPointM + 31: undefined // MultiPatch + }; + + return allTypes[geometryTypeNumber]; + }; + + const getAttributeType = (dbfFieldType: string) => { + switch (dbfFieldType) { + case 'C': // Character + return 'java.lang.String'; + case 'D': // Date + return 'java.util.Date'; + case 'N': // Numeric + return 'java.lang.Long'; + case 'F': // Floating point + return 'java.lang.Double'; + case 'L': // Logical + return 'java.lang.Boolean'; + case 'M': // Memo + return undefined; + default: + return undefined; + } + }; + + const onFileUploadAction = async (options: UploadRequestOption) => { + const { + onError, + onSuccess, + file + } = options; + + const splittedFileName = (file as RcFile).name.split('.'); + const fileType = (file as RcFile).type; + const geoServerBaseUrl = config.geoserver?.base || '/geoserver'; + const workspace = config.geoserver?.upload?.workspace || 'SHOGUN'; + const layerName = `${splittedFileName[0]}_${Date.now()}`.toUpperCase(); + + const uploadData = { + file: file as RcFile, + baseUrl: geoServerBaseUrl, + workspace: workspace, + storeName: layerName, + layerName: layerName + }; + + try { + if (fileType === 'image/tiff') { + await uploadGeoTiff(uploadData); + } + + if (fileType === 'application/zip' || fileType === 'application/x-zip-compressed') { + await uploadShapeZip(uploadData); + } + + onSuccess?.({ + baseUrl: geoServerBaseUrl, + workspace: workspace, + layerName: layerName + }); + } catch (error) { + onError?.({ + name: 'UploadError', + message: (error as Error)?.message + }); + } + }; + + const onFileUploadChange = async (info: UploadChangeParam>) => { + const file = info.file; + + if (file.status === 'uploading') { + setIsUploadingFile(true); + } + + if (file.status === 'done') { + await client?.layer().add({ + name: file.response?.layerName ?? 'LAYER-DEFAULT-NAME', + type: 'TILEWMS', + clientConfig: { + hoverable: false + }, + sourceConfig: { + url: `${file.response?.baseUrl}/ows?`, + layerNames: `${file.response?.workspace}:${file.response?.layerName}`, + useBearerToken: true + } + }); + + // Refresh the list + // TODO + // await fetchEntities(); + + // Finally, show success message + setIsUploadingFile(false); + + notification.success({ + message: t('UploadLayerButton.success.message'), + description: t('UploadLayerButton.success.description', { + fileName: file.fileName, + layerName: file.response?.layerName + }), + }); + + onSuccessProp?.(file.response); + } else if (file.status === 'error') { + setIsUploadingFile(false); + + Logger.error(file.error); + + notification.error({ + message: t('UploadLayerButton.error.message'), + description: t('UploadLayerButton.error.description', { + fileName: file.fileName + }) + }); + + onErrorProp?.(file.error); + } + }; + + return ( + + + + ); +}; + +export default UploadLayerButton; diff --git a/src/Context/GeneralEntityRootContext.tsx b/src/Context/GeneralEntityRootContext.tsx new file mode 100644 index 00000000..7d5f8dfd --- /dev/null +++ b/src/Context/GeneralEntityRootContext.tsx @@ -0,0 +1,30 @@ +import BaseEntity from '@terrestris/shogun-util/dist/model/BaseEntity'; +import React from 'react'; + +type ContextValue = { + fetchEntities?: () => Promise; + entityType?: string; + entities?: T[]; +}; + +export type GeneralEntityRootProps = { + value: ContextValue; + children: JSX.Element; +}; + +export const GeneralEntityRootContext = React.createContext<(ContextValue | undefined)>(undefined); + +export const GeneralEntityRootProvider = ({ + value, + children +}: GeneralEntityRootProps): JSX.Element => { + return ( + + {children} + + ); +}; + +export default GeneralEntityRootContext; diff --git a/src/Page/Portal/Portal.tsx b/src/Page/Portal/Portal.tsx index 66cd0ac5..0d8fac56 100644 --- a/src/Page/Portal/Portal.tsx +++ b/src/Page/Portal/Portal.tsx @@ -36,22 +36,25 @@ import Navigation from '../../Component/Menu/Navigation/Navigation'; import MetricsRoot from '../../Component/Metrics/MetricsRoot/MetricsRoot'; import ApplicationInfo from '../../Component/Modal/ApplicationInfo/ApplicationInfo'; import WelcomeDashboard from '../../Component/WelcomeDashboard/WelcomeDashboard'; +import ToolbarCreateAllUsersButton from '../../Component/GeneralEntity/Slots/ToolbarCreateAllUsersButton/ToolbarCreateAllUsersButton'; +import ToolbarCreateAllGroupsButton from '../../Component/GeneralEntity/Slots/ToolbarCreateAllGroupsButton/ToolbarCreateAllGroupsButton'; +import ToolbarCreateAllRolesButton from '../../Component/GeneralEntity/Slots/ToolbarCreateAllRolesButton/ToolbarCreateAllRolesButton'; +import ToolbarUploadLayerButton from '../../Component/GeneralEntity/Slots/ToolbarUploadLayerButton/ToolbarUploadLayerButton'; import { layerSuggestionListAtom } from '../../State/atoms'; -interface OwnProps { } - -type PortalProps = OwnProps; +type PortalProps = {}; export const Portal: React.FC = () => { const [collapsed, setCollapsed] = useState(false); - const toggleCollapsed = () => setCollapsed(!collapsed); const [entitiesToLoad, setEntitiesToLoad] = useState[]>([]); const [configsAreLoading, setConfigsAreLoading] = useState(false); const setLayerSuggestionList = useSetRecoilState(layerSuggestionListAtom); + const toggleCollapsed = () => setCollapsed(!collapsed); + const fetchConfigForModel = async (modelName: string): Promise> => { const reqOpts = { method: 'GET', @@ -91,6 +94,21 @@ export const Portal: React.FC = () => { } }, [setLayerSuggestionList]); + const getLeftToolbarItems = (entityType: string): React.ReactNode => { + switch (entityType) { + case 'layer': + return ; + case 'user': + return + case 'group': + return ; + case 'role': + return ; + default: + break; + } + }; + if (config?.models && config?.models?.length !== entitiesToLoad?.length && !configsAreLoading) { fetchConfigsForModels(); } @@ -125,8 +143,11 @@ export const Portal: React.FC = () => { element={ } /> diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 60e576a3..906635a5 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -49,20 +49,6 @@ export default { save: '{{entity}} speichern', reset: '{{entity}} zurücksetzen', create: '{{entity}} erstellen', - upload: { - success: { - message: '{{entity}} erfolgreich erstellt', - description: 'Datei {{fileName}} wurde erfolgreich geladen und der Layer {{layerName}} erstellt' - }, - error: { - message: 'Konnte {{entity}} nicht erstellen', - description: 'Fehler beim Hochladen der Datei {{fileName}}', - descriptionSize: 'Der Upload überschreitet das Limit von {{maxSize}} MB', - descriptionFormat: 'Der Dateityp ist nicht unterstützt ({{supportedFormats}})', - descriptionZipContent: 'Mehrere Geodatensätze innerhalb eines Archivs sind nicht unterstützt' - }, - button: '{{entity}} hochladen' - }, reminderModal: { title: 'Änderungen speichern?', description: 'Die Änderungen wurden noch nicht gespeichert, möchten Sie speichern?', @@ -250,10 +236,42 @@ export default { addLayerErrorMsg: 'Fehler beim Hinzufügen des Layers zur Karte.', loadLayerErrorMsg: 'Fehler beim Laden des Layers.', extentNotSupportedErrorMsg: 'Zoomen auf Gesamtansicht wird für diesen Typ nicht unterstützt.' + }, + VerifyProviderDetailsField: { + title: 'Keine Informationen des Authentication-Providers verfügbar' + }, + UploadLayerButton: { + success: { + message: 'Layer erfolgreich erstellt', + description: 'Datei {{fileName}} wurde erfolgreich geladen und der Layer {{layerName}} erstellt' + }, + error: { + message: 'Konnte Layer nicht erstellen', + description: 'Fehler beim Hochladen der Datei {{fileName}}', + descriptionSize: 'Der Upload überschreitet das Limit von {{maxSize}} MB', + descriptionFormat: 'Der Dateityp ist nicht unterstützt ({{supportedFormats}})', + descriptionZipContent: 'Mehrere Geodatensätze innerhalb eines Archivs sind nicht unterstützt' + }, + title: 'Layer erstellen' + }, + CreateAllUsersButton: { + title: 'Nutzer synchronisieren', + tooltip: 'Add all missing users from the user provider', + success: 'Nutzer erfolgreich erstellt', + error: 'Fehler beim Erstellen der Nutzer' + }, + CreateAllGroupsButton: { + title: 'Gruppen synchronisieren', + tooltip: 'Add all missing users from the user provider', + success: 'Gruppen erfolgreich erstellt', + error: 'Fehler beim Erstellen der Gruppen' + }, + CreateAllRolesButton: { + title: 'Rollen synchronisieren', + tooltip: 'Add all missing users from the user provider', + success: 'Rollen erfolgreich erstellt', + error: 'Fehler beim Erstellen der Rollen' } - }, - VerifyProviderDetailsField: { - title: 'Keine Informationen des Authentication-Providers verfügbar' } }, en: { @@ -306,20 +324,6 @@ export default { save: 'Save {{entity}}', reset: 'Reset {{entity}}', create: 'Create {{entity}}', - upload: { - success: { - message: '{{entity}} successfully created', - description: 'Successfully uploaded file {{fileName}} and created layer {{layerName}}' - }, - error: { - message: 'Could not create {{entity}}', - description: 'Error while uploading file {{fileName}}', - descriptionSize: 'The file exceeds the upload limit of {{maxSize}} MB', - descriptionFormat: 'The given file type does not match the supported ones ({{supportedFormats}})', - descriptionZipContent: 'Multiple geodatasets within one archive are not supported' - }, - button: 'Upload {{entity}}' - }, reminderModal: { title: 'Save changes?', description: 'The changes have not yet been saved, do you want to save?', @@ -523,6 +527,38 @@ export default { }, VerifyProviderDetailsField: { title: 'No authentication provider information available' + }, + UploadLayerButton: { + success: { + message: 'Layer successfully created', + description: 'Successfully uploaded file {{fileName}} and created layer {{layerName}}' + }, + error: { + message: 'Could not create layer', + description: 'Error while uploading file {{fileName}}', + descriptionSize: 'The file exceeds the upload limit of {{maxSize}} MB', + descriptionFormat: 'The given file type does not match the supported ones ({{supportedFormats}})', + descriptionZipContent: 'Multiple geodatasets within one archive are not supported' + }, + title: 'Create layer' + }, + CreateAllUsersButton: { + title: 'Add users', + tooltip: 'Add all missing users from the user provider', + success: 'Successfully created all users', + error: 'Could not create the users' + }, + CreateAllGroupsButton: { + title: 'Add groups', + tooltip: 'Add all missing groups from the group provider', + success: 'Successfully created all groups', + error: 'Could not create the groups' + }, + CreateAllRolesButton: { + title: 'Add roles', + tooltip: 'Add all missing roles from the role provider', + success: 'Successfully created all roles', + error: 'Could not create the roles' } } } diff --git a/src/test-util.tsx b/src/test-util.tsx index ceb4d493..6f9a2605 100644 --- a/src/test-util.tsx +++ b/src/test-util.tsx @@ -4,6 +4,11 @@ import * as React from 'react'; import { render,RenderOptions } from '@testing-library/react'; import { MutableSnapshot, RecoilRoot } from 'recoil'; +// A type to make all properties - except of the specified ones - of +// an input object optional. +// Example: PartialOmit +export type PartialOmit = Pick & Omit, K>; + const customRender = (ui: React.ReactElement>, recoilInitializer?: ((mutableSnapshot: MutableSnapshot) => void) | undefined, options?: RenderOptions) => {