Skip to content

Commit

Permalink
Merge pull request #1606 from digitalfabrik/1571-import-stores-backend
Browse files Browse the repository at this point in the history
1571: Import stores backend
  • Loading branch information
f1sh1918 authored Sep 24, 2024
2 parents e201e75 + 33c7c05 commit 5bd4139
Show file tree
Hide file tree
Showing 32 changed files with 680 additions and 121 deletions.
4 changes: 4 additions & 0 deletions administration/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ Object.assign(global, { TextDecoder, TextEncoder })
Object.assign(window, { isSecureContext: true })
Object.assign(window.crypto, { subtle })
Object.assign(window.URL, { createObjectURL: jest.fn() })

jest.mock('csv-stringify/browser/esm/sync', () => ({
stringify: jest.fn(),
}))
3 changes: 3 additions & 0 deletions administration/src/bp-modules/ButtonBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Card } from '@blueprintjs/core'
import styled from 'styled-components'

import dimensions from './constants/dimensions'

const ButtonBar = styled(Card)`
width: 100%;
padding: 15px;
Expand All @@ -12,6 +14,7 @@ const ButtonBar = styled(Card)`
& button {
margin: 5px;
}
height: ${dimensions.bottomBarHeight}px;
`

export default ButtonBar
2 changes: 2 additions & 0 deletions administration/src/bp-modules/NavigationBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { WhoAmIContext } from '../WhoAmIProvider'
import { Role } from '../generated/graphql'
import { ProjectConfigContext } from '../project-configs/ProjectConfigContext'
import UserMenu from './UserMenu'
import dimensions from './constants/dimensions'

const PrintAwareNavbar = styled(Navbar)`
@media print {
display: none;
}
height: ${dimensions.navigationBarHeight};
`

interface Props {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@ import bayernConfig from '../../project-configs/bayern/config'
import CreateCardsButtonBar from './CreateCardsButtonBar'

jest.useFakeTimers()
jest.mock('csv-stringify/browser/esm/sync', () => ({
stringify: jest.fn(),
}))

const wrapper = ({ children }: { children: ReactElement }) => <ProjectConfigProvider>{children}</ProjectConfigProvider>

Expand Down
4 changes: 0 additions & 4 deletions administration/src/bp-modules/cards/ImportCardsInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ import { AppToasterProvider } from '../AppToaster'
import { getHeaders } from './ImportCardsController'
import ImportCardsInput, { ENTRY_LIMIT } from './ImportCardsInput'

jest.mock('csv-stringify/browser/esm/sync', () => ({
stringify: jest.fn(),
}))

jest.mock('../../Router', () => ({}))

const wrapper = ({ children }: { children: ReactElement }) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ jest.mock('../../../cards/createCards', () => ({
__esModule: true,
default: jest.fn(),
}))
jest.mock('csv-stringify/browser/esm/sync', () => ({
stringify: jest.fn(),
}))
jest.mock('../../../cards/deleteCards')
jest.mock('../../../util/downloadDataUri')

Expand Down
10 changes: 10 additions & 0 deletions administration/src/bp-modules/constants/dimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type DimensionsType = {
navigationBarHeight: number
bottomBarHeight: number
}

const dimensions: DimensionsType = {
navigationBarHeight: 50,
bottomBarHeight: 70,
}
export default dimensions
38 changes: 33 additions & 5 deletions administration/src/bp-modules/stores/StoresButtonBar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Button, Tooltip } from '@blueprintjs/core'
import React, { ReactElement } from 'react'
import { Alert, Button, Tooltip } from '@blueprintjs/core'
import React, { ReactElement, useState } from 'react'

import ButtonBar from '../ButtonBar'
import { AcceptingStoreEntry } from './AcceptingStoreEntry'
import StoresImportAlert from './StoresImportAlert'

type UploadStoresButtonBarProps = {
goBack: () => void
acceptingStores: AcceptingStoreEntry[]
importStores: () => void
dryRun: boolean
setDryRun: (value: boolean) => void
}

const getToolTipMessage = (hasNoAcceptingStores: boolean, hasInvalidStores: boolean): string => {
Expand All @@ -20,22 +23,47 @@ const getToolTipMessage = (hasNoAcceptingStores: boolean, hasInvalidStores: bool
return 'Importiere Akzeptanzpartner'
}

const StoresButtonBar = ({ goBack, acceptingStores, importStores }: UploadStoresButtonBarProps): ReactElement => {
const StoresButtonBar = ({
goBack,
acceptingStores,
importStores,
dryRun,
setDryRun,
}: UploadStoresButtonBarProps): ReactElement => {
const hasInvalidStores = !acceptingStores.every(store => store.isValid())
const hasNoAcceptingStores = acceptingStores.length === 0
const [importDialogIsOpen, setImportDialogIsOpen] = useState(false)
const confirmImportDialog = () => {
importStores()
setImportDialogIsOpen(false)
}

return (
<ButtonBar>
<Button icon='arrow-left' text='Zurück zur Auswahl' onClick={goBack} />
<Tooltip placement='top' content={getToolTipMessage(hasNoAcceptingStores, hasInvalidStores)} disabled={false}>
<Tooltip
placement='top'
content={getToolTipMessage(hasNoAcceptingStores, hasInvalidStores)}
disabled={false}
openOnTargetFocus={false}>
<Button
icon='upload'
text='Import Stores'
intent='success'
onClick={importStores}
onClick={() => setImportDialogIsOpen(true)}
disabled={hasNoAcceptingStores || hasInvalidStores}
/>
</Tooltip>
<Alert
cancelButtonText='Abbrechen'
confirmButtonText='Stores importieren'
icon='upload'
intent='warning'
isOpen={importDialogIsOpen}
onCancel={() => setImportDialogIsOpen(false)}
onConfirm={confirmImportDialog}>
<StoresImportAlert dryRun={dryRun} setDryRun={setDryRun} storesCount={acceptingStores.length} />
</Alert>
</ButtonBar>
)
}
Expand Down
28 changes: 25 additions & 3 deletions administration/src/bp-modules/stores/StoresCSVInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { StoreFieldConfig } from '../../project-configs/getProjectConfig'
import { useAppToaster } from '../AppToaster'
import FileInputStateIcon, { FileInputStateType } from '../FileInputStateIcon'
import { AcceptingStoreEntry } from './AcceptingStoreEntry'
import StoresImportDuplicates from './StoresImportDuplicates'
import StoresRequirementsText from './StoresRequirementsText'

const StoreImportInputContainer = styled.div`
Expand Down Expand Up @@ -35,20 +36,34 @@ const lineToStoreEntry = (line: string[], headers: string[], fields: StoreFieldC
const storeData = line.reduce((acc, entry, index) => {
const columnName = headers[index]
// TODO 1570 get geodata if no coordinates available
return { ...acc, [columnName]: entry }
return { ...acc, [columnName]: entry.trim() }
}, {})
return new AcceptingStoreEntry(storeData, fields)
}

const getStoreDuplicates = (stores: AcceptingStoreEntry[]): number[][] => {
return Object.values(
stores.reduce((acc: Record<string, number[]>, entry, index) => {
const { data } = entry
const groupKey = JSON.stringify([data.name, data.street, data.houseNumber, data.postalCode, data.location])
const entryNumber = index + 1
if (acc[groupKey] === undefined) {
return { ...acc, [groupKey]: [entryNumber] }
}
return { ...acc, [groupKey]: [...acc[groupKey], entryNumber] }
}, {})
).filter(entryNumber => entryNumber.length > 1)
}

const StoresCsvInput = ({ setAcceptingStores, fields }: StoresCsvInputProps): ReactElement => {
const [inputState, setInputState] = useState<FileInputStateType>('idle')
const fileInput = useRef<HTMLInputElement>(null)
const appToaster = useAppToaster()
const headers = fields.map(field => field.name)

const showInputError = useCallback(
(message: string) => {
appToaster?.show({ intent: 'danger', message })
(message: string | ReactElement, timeout?: number) => {
appToaster?.show({ intent: 'danger', message, timeout })
setInputState('error')
if (!fileInput.current) return
fileInput.current.value = ''
Expand Down Expand Up @@ -102,6 +117,13 @@ const StoresCsvInput = ({ setAcceptingStores, fields }: StoresCsvInputProps): Re
}
const acceptingStores = lines.map((line: string[]) => lineToStoreEntry(line, csvHeader, fields))

const duplicatedStoreEntries = getStoreDuplicates(acceptingStores)
if (duplicatedStoreEntries.length > 0) {
const message = <StoresImportDuplicates entries={duplicatedStoreEntries} />
showInputError(message, 0)
return
}

setAcceptingStores(acceptingStores)
setInputState('idle')
},
Expand Down
58 changes: 58 additions & 0 deletions administration/src/bp-modules/stores/StoresImportAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Checkbox } from '@blueprintjs/core'
import React, { ReactElement } from 'react'
import styled from 'styled-components'

const StyledCheckbox = styled(Checkbox)`
margin: 12px 0;
align-self: center;
`

const CheckboxContainer = styled.div`
padding: 0 12px;
display: flex;
`

const DurationContainer = styled.div`
margin-top: 12px;
`

type StoreImportAlertProps = {
dryRun: boolean
setDryRun: (value: boolean) => void
storesCount: number
}

const STORES_COUNT_NOTE_THRESHOLD = 500
const STORES_IMPORT_PER_SECOND = 100
const StoresImportAlert = ({ dryRun, setDryRun, storesCount }: StoreImportAlertProps): ReactElement => {
return (
<>
{dryRun ? (
<span data-testid='dry-run-alert'>
<b>Testlauf:</b> In diesem Testlauf wird nur simuliert, wie viele Akzeptanzpartner geändert oder gelöscht
werden würden. Es werden noch keine Änderungen an der Datenbank vorgenommen.
</span>
) : (
<>
<span data-testid='prod-run-alert'>
<b>Achtung:</b> Akzeptanzpartner, welche aktuell in der Datenbank gespeichert, aber nicht in der Tabelle
vorhanden sind, werden gelöscht!
</span>
<br />
{storesCount > STORES_COUNT_NOTE_THRESHOLD && (
<DurationContainer data-testid={'duration-alert'}>
<b>Geschätzte Dauer des Imports:</b> {Math.ceil(storesCount / STORES_IMPORT_PER_SECOND / 60)} Minuten.{' '}
<br />
Bitte schließen sie das Browserfenster nicht!
</DurationContainer>
)}
</>
)}
<CheckboxContainer>
<StyledCheckbox checked={dryRun} onChange={e => setDryRun(e.currentTarget.checked)} label='Testlauf' />
</CheckboxContainer>
</>
)
}

export default StoresImportAlert
42 changes: 36 additions & 6 deletions administration/src/bp-modules/stores/StoresImportController.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NonIdealState } from '@blueprintjs/core'
import { NonIdealState, Spinner } from '@blueprintjs/core'
import React, { ReactElement, useContext, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components'

import { WhoAmIContext } from '../../WhoAmIProvider'
import getMessageFromApolloError from '../../errors/getMessageFromApolloError'
Expand All @@ -11,6 +12,7 @@ import { useAppToaster } from '../AppToaster'
import { AcceptingStoreEntry } from './AcceptingStoreEntry'
import StoresButtonBar from './StoresButtonBar'
import StoresCSVInput from './StoresCSVInput'
import StoresImportResult from './StoresImportResult'
import StoresTable from './StoresTable'

const StoresImportController = (): ReactElement => {
Expand All @@ -29,6 +31,13 @@ const StoresImportController = (): ReactElement => {
return <StoresImport fields={storeManagement.fields} />
}

const CenteredSpinner = styled(Spinner)`
z-index: 999;
top: 50%;
left: 50%;
position: fixed;
`

type StoreImportProps = {
fields: StoreFieldConfig[]
}
Expand All @@ -37,12 +46,25 @@ export type StoreData = {
[key: string]: string
}
const StoresImport = ({ fields }: StoreImportProps): ReactElement => {
const { projectId } = useContext(ProjectConfigContext)
const navigate = useNavigate()
const appToaster = useAppToaster()
const [acceptingStores, setAcceptingStores] = useState<AcceptingStoreEntry[]>([])
const [importStores] = useImportAcceptingStoresMutation({
onCompleted: () => {
appToaster?.show({ intent: 'success', message: 'Ihre Akzeptanzpartner wurden importiert.' })
const [dryRun, setDryRun] = useState<boolean>(false)
const [importStores, { loading: isApplyingStoreTransaction }] = useImportAcceptingStoresMutation({
onCompleted: ({ result }) => {
appToaster?.show({
intent: 'none',
timeout: 0,
message: (
<StoresImportResult
dryRun={dryRun}
storesUntouched={result.storesUntouched}
storesDeleted={result.storesDeleted}
storesCreated={result.storesCreated}
/>
),
})
setAcceptingStores([])
},
onError: error => {
Expand All @@ -59,16 +81,24 @@ const StoresImport = ({ fields }: StoreImportProps): ReactElement => {
}
}

const onImportStores = () => importStores({ variables: { stores: acceptingStores.map(store => store.data) } })
const onImportStores = () =>
importStores({ variables: { stores: acceptingStores.map(store => store.data), project: projectId, dryRun } })

return (
<>
{isApplyingStoreTransaction && <CenteredSpinner intent='primary' />}
{acceptingStores.length === 0 ? (
<StoresCSVInput setAcceptingStores={setAcceptingStores} fields={fields} />
) : (
<StoresTable fields={fields} acceptingStores={acceptingStores} />
)}
<StoresButtonBar goBack={goBack} acceptingStores={acceptingStores} importStores={onImportStores} />
<StoresButtonBar
goBack={goBack}
acceptingStores={acceptingStores}
importStores={onImportStores}
dryRun={dryRun}
setDryRun={setDryRun}
/>
</>
)
}
Expand Down
23 changes: 23 additions & 0 deletions administration/src/bp-modules/stores/StoresImportDuplicates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { ReactElement } from 'react'
import styled from 'styled-components'

type StoresImportDuplicatesProps = { entries: number[][] }

const Container = styled.div`
display: flex;
flex-direction: column;
`
const StoresImportDuplicates = ({ entries }: StoresImportDuplicatesProps): ReactElement => {
return (
<Container>
Die CSV enthält doppelte Einträge:
{entries.map(entry => {
const entries = entry.join(', ')
return <span key={entries}>Die Einträge {entries} sind identisch.</span>
})}
Bitte löschen Sie die doppelten Einträge.
</Container>
)
}

export default StoresImportDuplicates
Loading

0 comments on commit 5bd4139

Please sign in to comment.