diff --git a/package.json b/package.json index 5cd9a11a..5355d5d9 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@apollo/react-hooks": "4.0.0", "@contentful/rich-text-react-renderer": "15.1.0", "@contentful/rich-text-types": "15.1.0", - "@dekk-app/dekk-backend": "1.5.3", + "@dekk-app/dekk-backend": "1.6.1", "@emotion/cache": "11.4.0", "@emotion/core": "11.0.0", "@emotion/react": "11.4.1", diff --git a/public/static/locales/de/form.json b/public/static/locales/de/form.json index b61770a5..be801951 100644 --- a/public/static/locales/de/form.json +++ b/public/static/locales/de/form.json @@ -8,7 +8,9 @@ "minLength": "Die Mindestlänge ist {{minLength}}.", "passwordMatch": "Die Passwörter stimmen nicht überein.", "pattern": "Überprüfe bitte deine Eingabe.", - "required": "Dieses Feld ist ein Pflichtfeld." + "required": "Dieses Feld ist ein Pflichtfeld.", + "generic-error": "Ein Fehler ist aufgetreten.", + "CANNOT_UPDATE_VOTED_WISH": "Ein Wunsch mit Stimmen kann nicht bearbeitet werden." }, "fields-labels": { "confirmPassword": "Passwort bestätigen", diff --git a/public/static/locales/en/form.json b/public/static/locales/en/form.json index c1b7a3d1..fa587504 100644 --- a/public/static/locales/en/form.json +++ b/public/static/locales/en/form.json @@ -8,7 +8,9 @@ "minLength": "The minimum length is {{minLength}}.", "passwordMatch": "The passwords do not match.", "pattern": "Please check your input.", - "required": "This field is required." + "required": "This field is required.", + "generic-error": "An error occurred.", + "CANNOT_UPDATE_VOTED_WISH": "Cannot update a wish that already has votes." }, "fields-labels": { "confirmPassword": "Confirm password", diff --git a/src/atoms/error-text/styled.ts b/src/atoms/error-text/styled.ts index 18da2416..e94112ef 100644 --- a/src/atoms/error-text/styled.ts +++ b/src/atoms/error-text/styled.ts @@ -2,30 +2,32 @@ import { pxToRem } from "@/ions/utils/unit"; import { css } from "@emotion/react"; import styled from "@emotion/styled"; -export const StyledErrorText = styled.div` +export interface StyledErrorTextProps { + arrow?: boolean; +} +export const StyledErrorText = styled.div` --tip-size: ${pxToRem(8)}; position: relative; width: 100%; margin: ${pxToRem(-6)} 0 ${pxToRem(12)}; padding: ${pxToRem(6)} ${pxToRem(24)}; - - &::before { - content: ""; - position: absolute; - bottom: 100%; - left: calc(var(--tip-size) * 2); - border: var(--tip-size) solid transparent; - border-top: 0; - } - - ${({ theme }) => css` + ${({ theme, arrow }) => css` border-radius: ${theme.shapes.s}; background: ${theme.ui.atoms.errorText.background}; color: ${theme.ui.atoms.errorText.color}; - &::before { - border-bottom-color: ${theme.ui.atoms.errorText.background}; - } + ${arrow && + css` + &::before { + content: ""; + position: absolute; + bottom: 100%; + left: calc(var(--tip-size) * 2); + border: var(--tip-size) solid transparent; + border-bottom-color: ${theme.ui.atoms.errorText.background}; + border-top: 0; + } + `}; `}; `; diff --git a/src/atoms/icon-button/types.ts b/src/atoms/icon-button/types.ts index d98fa959..5abce2da 100644 --- a/src/atoms/icon-button/types.ts +++ b/src/atoms/icon-button/types.ts @@ -1,3 +1,6 @@ -import { HTMLAttributes } from "react"; +import { HTMLAttributes, HTMLProps } from "react"; +import { Except } from "type-fest"; -export interface IconButtonProps extends HTMLAttributes {} +export interface IconButtonProps + extends HTMLAttributes, + Except, "as" | "type"> {} diff --git a/src/atoms/input-wrapper/styled.ts b/src/atoms/input-wrapper/styled.ts index b293b3ba..1b567a2c 100644 --- a/src/atoms/input-wrapper/styled.ts +++ b/src/atoms/input-wrapper/styled.ts @@ -1,8 +1,9 @@ import { pxToRem } from "@/ions/utils/unit"; import { css } from "@emotion/react"; import styled from "@emotion/styled"; +import { StyledInputWrapperProps } from "./types"; -export const StyledInputWrapper = styled.label<{ focused?: boolean; fullWidth?: boolean }>` +export const StyledInputWrapper = styled.label` position: relative; margin: 0 auto ${pxToRem(16)}; padding: 0; @@ -19,8 +20,12 @@ export const StyledInputWrapper = styled.label<{ focused?: boolean; fullWidth?: pointer-events: none; } - ${({ theme, fullWidth }) => css` + ${({ theme, fullWidth, disabled }) => css` width: ${fullWidth ? "100%" : "auto"}; + ${disabled && + css` + opacity: 0.5; + `}; &::before { border-radius: ${theme.shapes.s}; box-shadow: inset 0 0 0 1px currentColor; diff --git a/src/atoms/input-wrapper/types.ts b/src/atoms/input-wrapper/types.ts new file mode 100644 index 00000000..9541cae0 --- /dev/null +++ b/src/atoms/input-wrapper/types.ts @@ -0,0 +1,5 @@ +export interface StyledInputWrapperProps { + focused?: boolean; + fullWidth?: boolean; + disabled?: boolean; +} diff --git a/src/ions/contexts/wishlist/index.tsx b/src/ions/contexts/wishlist/index.tsx index 194f661f..85ba665d 100644 --- a/src/ions/contexts/wishlist/index.tsx +++ b/src/ions/contexts/wishlist/index.tsx @@ -12,16 +12,21 @@ import { UpdateCallback, WishlistState } from "./types"; export const WishlistContext = createContext({ wishes: [], + error: null, add() { /**/ }, update() { /**/ }, + setError() { + /**/ + }, }); export const WishlistProvider: FC<{ initialState: Wish[] }> = ({ children, initialState }) => { const [wishes, setWishes] = useState(initialState); + const [error, setError] = useState(null); const add = useCallback((wish: Wish) => { setWishes(previousState => [wish, ...previousState]); @@ -49,8 +54,10 @@ export const WishlistProvider: FC<{ initialState: Wish[] }> = ({ children, initi wishes, add, update, + setError, + error, }), - [add, wishes, update] + [add, wishes, update, setError, error] ); return {children}; diff --git a/src/ions/contexts/wishlist/types.ts b/src/ions/contexts/wishlist/types.ts index d2cca50e..4e6d3fdc 100644 --- a/src/ions/contexts/wishlist/types.ts +++ b/src/ions/contexts/wishlist/types.ts @@ -1,9 +1,12 @@ import { Wish } from "@/types/backend-api"; +import { Dispatch, SetStateAction } from "react"; export type UpdateCallback = (previousWish: Wish) => Partial; export interface WishlistState { wishes: Wish[]; + error: string; + setError: Dispatch>; add(wish: Wish): void; update(id: number, callback: UpdateCallback): void; } diff --git a/src/molecules/input-field/index.tsx b/src/molecules/input-field/index.tsx index d66753a6..428abca7 100644 --- a/src/molecules/input-field/index.tsx +++ b/src/molecules/input-field/index.tsx @@ -18,6 +18,8 @@ const InputField: FC = ({ fullWidth, defaultValue, autoFocus, + readOnly, + disabled, helpText, required, validation = {}, @@ -41,7 +43,12 @@ const InputField: FC = ({ return ( <> - + = ({ id={`${id}_field`} name={name} autoFocus={autoFocus} + readOnly={readOnly} + disabled={disabled} required={Boolean(validation.required)} invalid={Boolean(errors[name])} type={type} @@ -83,7 +92,7 @@ const InputField: FC = ({ {errors[name] ? ( - + {t(`form:errors.${(errors[name] as FieldError).type as string}`, { minLength: 2, diff --git a/src/molecules/snackbar/index.tsx b/src/molecules/snackbar/index.tsx new file mode 100644 index 00000000..03ab4ddd --- /dev/null +++ b/src/molecules/snackbar/index.tsx @@ -0,0 +1,50 @@ +import Icon from "@/atoms/icon"; +import { useTranslation } from "next-i18next"; +import React, { FC, useEffect } from "react"; +import { StyledSnackbar, StyledSnackbarButton, StyledSnackbarMessage } from "./styled"; +import { SnackbarProps } from "./types"; + +const Snackbar: FC = ({ + children, + id, + level = "default", + autoClose = 6000, + onClose, + fixed, + ...props +}) => { + const { t } = useTranslation(["common"]); + useEffect(() => { + if (onClose && autoClose > 0) { + const timer = setTimeout(() => { + onClose(); + }, autoClose); + return () => { + onClose(); + clearTimeout(timer); + }; + } + + return () => { + /**/ + }; + }, [onClose, autoClose]); + return ( + + {children} + {onClose && ( + + + + )} + + ); +}; + +export default Snackbar; diff --git a/src/molecules/snackbar/styled.ts b/src/molecules/snackbar/styled.ts new file mode 100644 index 00000000..012c8146 --- /dev/null +++ b/src/molecules/snackbar/styled.ts @@ -0,0 +1,53 @@ +import IconButton from "@/atoms/icon-button"; +import { shadows } from "@/ions/theme"; +import { pxToRem } from "@/ions/utils/unit"; +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { SnackbarBackgrounds, SnackbarColors, StyledSnackbarProps } from "./types"; + +export const StyledSnackbar = styled.div` + display: grid; + grid-template-columns: 1fr auto; + width: ${pxToRem(600)}; + ${({ theme, level, fixed }) => css` + ${fixed && + css` + position: fixed; + z-index: 5; + bottom: 0; + left: 50%; + transform: translateX(-50%); + box-shadow: ${shadows.l}; + `}; + grid-gap: ${pxToRem(theme.spaces.xs)}; + max-width: ${fixed ? `calc(100% - ${pxToRem(2 * theme.spaces.l)})` : "100%"}; + margin: ${pxToRem(theme.spaces.m)} 0; + padding: ${pxToRem(theme.spaces.s)} ${pxToRem(theme.spaces.m)}; + border-radius: ${theme.shapes.s}; + background: ${theme.palette[backgroundColors[level]]}; + color: ${colors[level]}; + `}; +`; + +const backgroundColors: SnackbarBackgrounds = { + default: "dark", + warning: "yellow", + info: "blue", + error: "red", +}; + +const colors: SnackbarColors = { + default: "white", + warning: "black", + info: "black", + error: "white", +}; + +export const StyledSnackbarButton = styled(IconButton)` + align-self: center; + ${({ theme }) => css` + margin: ${pxToRem(-theme.spaces.s)} ${pxToRem(-theme.spaces.xs)}; + `}; +`; + +export const StyledSnackbarMessage = styled.div``; diff --git a/src/molecules/snackbar/types.ts b/src/molecules/snackbar/types.ts new file mode 100644 index 00000000..99e0a048 --- /dev/null +++ b/src/molecules/snackbar/types.ts @@ -0,0 +1,20 @@ +import { Palette } from "@/types/theme"; + +export type SnackbarLevel = "warning" | "info" | "error" | "default"; + +export type SnackbarBackgrounds = Record; + +export type SnackbarColor = "black" | "white"; + +export type SnackbarColors = Record; + +export interface StyledSnackbarProps { + level?: SnackbarLevel; + fixed?: boolean; +} + +export interface SnackbarProps extends StyledSnackbarProps { + autoClose?: number; + id: string; + onClose?(): void; +} diff --git a/src/molecules/textarea-field/index.tsx b/src/molecules/textarea-field/index.tsx index 3a928a36..30dd9ffb 100644 --- a/src/molecules/textarea-field/index.tsx +++ b/src/molecules/textarea-field/index.tsx @@ -28,6 +28,8 @@ const TextArea: FC = ({ fullWidth, defaultValue, autoFocus, + readOnly, + disabled, helpText, required, validation = {}, @@ -57,7 +59,12 @@ const TextArea: FC = ({ return ( <> - + = ({ id={`${id}_field`} name={name} autoFocus={autoFocus} + readOnly={readOnly} + disabled={disabled} required={Boolean(validation.required)} invalid={Boolean(errors[name])} data-test-id={testId} @@ -95,7 +104,7 @@ const TextArea: FC = ({ {errors[name] ? ( - + {t(`form:errors.${(errors[name] as FieldError).type as string}`, { minLength: 2, diff --git a/src/organisms/add-wish-modal/index.tsx b/src/organisms/add-wish-modal/index.tsx index 8fd868e1..eaeea46f 100644 --- a/src/organisms/add-wish-modal/index.tsx +++ b/src/organisms/add-wish-modal/index.tsx @@ -1,4 +1,5 @@ import Button from "@/atoms/button"; +import { StyledErrorText } from "@/atoms/error-text/styled"; import Typography from "@/atoms/typography"; import { useAddWishModal } from "@/ions/contexts/add-wish-modal"; import { useWish } from "@/ions/contexts/wish"; @@ -15,9 +16,12 @@ import { Wish } from "@/types/backend-api"; import { useMutation } from "@apollo/client"; import { useSession } from "next-auth/client"; import { useTranslation } from "next-i18next"; +import dynamic from "next/dynamic"; import React, { useCallback, useEffect } from "react"; import { FormProvider, useForm } from "react-hook-form"; +const ButtonSpinner = dynamic(async () => import("@/atoms/spinner/button-spinner")); + const AddWishModal = () => { const [session] = useSession(); const { t } = useTranslation(["cancel", "form", "wishlist"]); @@ -32,9 +36,9 @@ const AddWishModal = () => { "wish-body": previousBody, }, }); - const { update: updateMyWish } = useWishlist(); + const { update: updateMyWish, setError: setWishlistError } = useWishlist(); - const [createWish, { data: dataCreateWish }] = useMutation<{ + const [createWish, { data: dataCreateWish, loading: loadingCreateWish }] = useMutation<{ createWish: Wish; }>(CREATE_WISH, { variables: { @@ -44,7 +48,10 @@ const AddWishModal = () => { }, }); - const [updateWish, { data: dataUpdateWish }] = useMutation<{ + const [ + updateWish, + { data: dataUpdateWish, error: errorUpdateWish, loading: loadingUpdateWish }, + ] = useMutation<{ updateWish: Wish; }>(UPDATE_WISH, { variables: { @@ -54,10 +61,31 @@ const AddWishModal = () => { }, }); + const loading = loadingUpdateWish || loadingCreateWish; + // Activate when the errors return keys + // const error = errorUpdateWish || errorCreateWish; + const handleSubmit = useCallback(async () => { - await (id ? updateWish() : createWish()); - close(); - }, [id, updateWish, createWish, close]); + if (id) { + updateWish() + .then(() => { + close(); + }) + .catch(() => { + close(); + setWishlistError(t("form:errors:CANNOT_UPDATE_VOTED_WISH")); + }); + } else { + createWish() + .then(() => { + close(); + }) + .catch(() => { + close(); + setWishlistError(t("form:errors:generic-error")); + }); + } + }, [id, updateWish, createWish, close, setWishlistError, t]); // Add new wish useEffect(() => { @@ -86,10 +114,16 @@ const AddWishModal = () => { {t("wishlist:add-wish.body")} + {errorUpdateWish && ( + + {t("form:errors:CANNOT_UPDATE_VOTED_WISH")} + + )} { />