Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1571: Import stores backend #1606

Merged
merged 14 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
}))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved this to the jest.setup for all tests using it


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

Expand Down
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} />
</Alert>
</ButtonBar>
)
}
Expand Down
34 changes: 31 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,40 @@ 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, 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] }
}, {} as { [key: string]: number[] })
).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 +123,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
41 changes: 41 additions & 0 deletions administration/src/bp-modules/stores/StoresImportAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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;
`

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

const StoresImportAlert = ({ dryRun, setDryRun }: StoreImportAlertProps): ReactElement => {
return (
<>
{dryRun ? (
<span data-testid='dry-run-alert'>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, testing with testId should only be done if no other queries are suitable as a last resort:
https://testing-library.com/docs/queries/about#priority

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes i'm aware of this priority.
I've chosen that way to not have to split up the strings and check them separate.

expect(getByText('Testlauf:')).toBeTruthy()
expect(getByText('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.')).toBeTruthy()

I think this is better readable for devs.

<b>Testlauf:</b> In diesem Testlauf wird nur simuliert, wie viele Stores geändert oder gelöscht werden würden.
Es werden keine Daten in die Datenbank geschrieben.
</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>
)}
<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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest to use 500 so we could possible put something before in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but what should be before an loading spinner f.e ? even its shown over a modal it should be highest index

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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Why 'none'?

Suggested change
intent: 'none',
intent: 'success',

Copy link
Contributor Author

@f1sh1918 f1sh1918 Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is more an overview that just a success message.
It looks really weird if everything is green.
Additionally there are some ugly styling issues using success

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
22 changes: 22 additions & 0 deletions administration/src/bp-modules/stores/StoresImportDuplicates.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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, index) => (
<span key={index}>Eintrag {entry.join(' und ')} sind identisch.</span>
))}
Bitte löschen Sie die doppelten Einträge.
</Container>
)
}

export default StoresImportDuplicates
Loading