feat: Add StringPoolField

This commit is contained in:
Myzel394 2023-02-10 20:16:44 +01:00
parent 6eacf14bae
commit 82a3641a6d
7 changed files with 276 additions and 2 deletions

View File

@ -33,6 +33,7 @@
"immutability-helper": "^3.1.1",
"in-milliseconds": "^1.2.0",
"in-seconds": "^1.2.0",
"lodash": "^4.17.21",
"notistack": "^2.0.8",
"openpgp": "^5.5.0",
"react": "^18.2.0",
@ -56,6 +57,7 @@
"@types/deep-equal": "^1.0.1",
"@types/group-array": "^1.0.1",
"@types/jest": "^29.2.4",
"@types/lodash": "^4.14.191",
"@types/node": "^18.11.18",
"@types/openpgp": "^4.4.18",
"@types/react-icons": "^3.0.0",

View File

@ -3,6 +3,7 @@
"cancelLabel": "Cancel",
"emptyValue": "-",
"emptyUnavailableValue": "Unavailable",
"saveLabel": "Save",
"defaultValueSelection": "Default <{{value}}>",
"defaultValueSelectionRaw": "<{{value}}>",
@ -345,7 +346,7 @@
"label": "Random alias character pool",
"description": "Characters that are used to generate random emails."
},
"randomEmailIdLengthIncreaseOnPercentage": {
"randomEmailLengthIncreaseOnPercentage": {
"label": "Percentage of used aliases",
"description": "If the percentage of used random email IDs is higher than this value, the length of the random email ID will be increased. This is used to prevent spammers from guessing the email ID."
},
@ -378,6 +379,10 @@
"description": "If enabled, your instance will collect anonymous statistics and share them. They will only be stored locally on this instance but made public."
}
}
},
"settings": {
"title": "Global Settings",
"description": "Configure global settings for your instance."
}
}
},
@ -458,6 +463,19 @@
"doNotShare": "Do not share",
"decideLater": "Decide later",
"doNotAskAgain": "Do not ask again"
},
"StringPoolField": {
"addCustom": {
"label": "Add custom"
},
"forms": {
"addNew": {
"title": "Add new value",
"description": "Enter your characters you would like to include",
"label": "Characters",
"submit": "Add"
}
}
}
},

View File

@ -1,7 +1,7 @@
import {client} from "~/constants/axios-client"
import {AdminSettings} from "~/server-types"
export default async function getAdminSettings(): Promise<AdminSettings> {
export default async function getAdminSettings(): Promise<Partial<AdminSettings>> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/settings`, {
withCredentials: true,
})

View File

@ -0,0 +1,68 @@
import {ReactElement, useState} from "react"
import {useTranslation} from "react-i18next"
import {MdCheck} from "react-icons/md"
import {TiCancel} from "react-icons/ti"
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from "@mui/material"
import {whenEnterPressed} from "~/utils"
export interface StringPoolFieldProps {
onCreated: (value: string) => void
onClose: () => void
open?: boolean
}
export default function AddNewDialog({
onCreated,
open = false,
onClose,
}: StringPoolFieldProps): ReactElement {
const {t} = useTranslation()
const [value, setValue] = useState<string>("")
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("components.StringPoolField.forms.addNew.title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("components.StringPoolField.forms.addNew.description")}
</DialogContentText>
<Box my={2}>
<TextField
value={value}
onChange={e => setValue(e.target.value)}
label={t("components.StringPoolField.forms.addNew.label")}
name="addNew"
fullWidth
autoFocus
onKeyUp={whenEnterPressed(() => onCreated(value))}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} startIcon={<TiCancel />} variant="text">
{t("general.cancelLabel")}
</Button>
<Button
onClick={() => onCreated(value)}
variant="contained"
startIcon={<MdCheck />}
>
{t("components.StringPoolField.forms.addNew.submit")}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -0,0 +1,183 @@
import {useTranslation} from "react-i18next"
import {MdAdd} from "react-icons/md"
import React, {ReactElement, useLayoutEffect, useMemo, useState} from "react"
import {
Box,
Checkbox,
Chip,
FormControl,
FormHelperText,
InputLabel,
ListItemIcon,
ListItemText,
MenuItem,
Select,
SelectProps,
} from "@mui/material"
import AddNewDialog from "./AddNewDialog"
export interface StringPoolFieldProps
extends Omit<SelectProps<string[]>, "onChange" | "value" | "multiple" | "labelId" | "label"> {
pools: Record<string, string>
label: string
value: string
onChange: SelectProps<string>["onChange"]
id: string
allowCustom?: boolean
helperText?: string | string[]
error?: boolean
}
export function createPool(pools: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(pools).map(([key, value]) => [key.split("").sort().join(""), value]),
)
}
export default function StringPoolField({
pools,
value,
helperText,
id,
error,
label,
onChange,
onOpen,
allowCustom,
name,
fullWidth,
...props
}: StringPoolFieldProps): ReactElement {
const {t} = useTranslation()
const reversedPoolsMap = useMemo(
() => Object.fromEntries(Object.entries(pools).map(([key, value]) => [value, key])),
[pools],
)
const [isInAddMode, setIsInAddMode] = useState<boolean>(false)
const [uiRemainingValue, setUiRemainingValue] = useState<string>("")
const selectedValueMaps = Object.entries(pools)
.filter(([key]) => value.includes(key))
.map(([, value]) => value)
const remainingValue = (() => {
// List of all characters inside the pools
const charactersInPools = Object.keys(pools).join("")
return value
.split("")
.filter(char => !charactersInPools.includes(char))
.join("")
})()
const selectValue = [...selectedValueMaps, remainingValue].filter(Boolean)
useLayoutEffect(() => {
if (remainingValue) {
setUiRemainingValue(remainingValue)
}
}, [remainingValue])
return (
<>
<FormControl sx={{minWidth: 180}} fullWidth={fullWidth} error={error}>
<InputLabel id={id} error={error}>
{label}
</InputLabel>
<Select<string[]>
multiple
name={name}
labelId={id}
renderValue={(selected: string[]) => (
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
{selected.map(value => (
<Chip key={value} label={value} />
))}
</Box>
)}
value={selectValue}
label={label}
onOpen={event => {
if (!remainingValue) {
setUiRemainingValue("")
}
onOpen?.(event)
}}
onChange={(event, child) => {
if (!Array.isArray(event.target.value)) {
return
}
const value = event.target.value.reduce((acc, value) => {
if (reversedPoolsMap[value]) {
return acc + reversedPoolsMap[value]
}
return acc + value
}, "")
onChange!(
// @ts-ignore
{
...event,
target: {
...event.target,
value: value as string,
},
},
child,
)
}}
{...props}
>
{Object.entries(pools).map(([poolValue, label]) => (
<MenuItem key={poolValue} value={label}>
<Checkbox checked={value.includes(poolValue)} />
<ListItemText primary={label} />
</MenuItem>
))}
{uiRemainingValue && (
<MenuItem value={uiRemainingValue}>
<Checkbox checked={uiRemainingValue === remainingValue} />
<ListItemText primary={uiRemainingValue} />
</MenuItem>
)}
{allowCustom && (
<MenuItem
onClick={event => {
event.preventDefault()
setIsInAddMode(true)
}}
>
<ListItemIcon>
<MdAdd />
</ListItemIcon>
<ListItemText
primary={t("components.StringPoolField.addCustom.label")}
/>
</MenuItem>
)}
</Select>
{helperText ? <FormHelperText error={error}>{helperText}</FormHelperText> : null}
</FormControl>
<AddNewDialog
onCreated={newValue => {
setIsInAddMode(false)
// @ts-ignore: This is enough for formik.
onChange({
target: {
name,
value: value + newValue,
},
})
}}
onClose={() => setIsInAddMode(false)}
open={isInAddMode}
/>
</>
)
}

View File

@ -0,0 +1,2 @@
export * from "./StringPoolField"
export {default as StringPoolField} from "./StringPoolField"

View File

@ -45,5 +45,6 @@ export {default as LoadingData} from "./LoadingData"
export * from "./ExternalLinkIndication"
export {default as ExternalLinkIndication} from "./ExternalLinkIndication"
export {default as ExtensionSignalHandler} from "./ExtensionalSignalHandler"
export * from "./StringPoolField"
export * as SimplePageBuilder from "./simple-page-builder"