diff --git a/src/components/SaveBox/index.tsx b/src/components/SaveBox/index.tsx index ecbec9775..48610ef33 100644 --- a/src/components/SaveBox/index.tsx +++ b/src/components/SaveBox/index.tsx @@ -1,91 +1,90 @@ -import fastDeepEqual from "fast-deep-equal"; -import { useEffect, useMemo, useState } from "react"; -import { Button, Group } from "@mantine/core"; -import { mdiCheck } from "@mdi/js"; -import { useLater } from "~/hooks/later"; +import classes from "./style.module.scss"; +import { Button, Group, Notification, Portal, clsx } from "@mantine/core"; +import { mdiCheck, mdiInformationOutline } from "@mdi/js"; import { Icon } from "../Icon"; -import { klona } from "klona"; -import { useStable } from "~/hooks/stable"; +import { SaveableHandle } from "~/hooks/save"; +import { ReactNode } from "react"; +import { capitalize } from "radash"; +import { Spacer } from "../Spacer"; export interface SaveBoxProps { - value: any; - valid?: boolean; - onPatch?: () => any; - onRevert?: (original: any) => void; - onSave: (original: any) => any; - onChangedState?: (isChanged: boolean) => void; + handle: SaveableHandle; + inline?: boolean; + position?: "left" | "center" | "right"; + saveText?: ReactNode; + revertText?: ReactNode; } /** - * To use this component effectively you should delay - * the mounting of the save box until after your remote - * data has been fetched and loaded. The data at the - * time of mounting will be seen as the "original" state. - * It is recommended to use the `useSaveBox` hook to - * streamline the integration of this component. - * - * The `value` prop should usually be assigned an object - * containing all state you want to track. This object - * will later be passed back by `onRevert` in order to - * reset each state hook to their original value. - * - * When `valid` is false, the save button will be disabled. - * - * Use the `onSave` prop to save the current state to - * the database. The data at the moment of saving will - * be seen as the new "original" state. - * - * Use the `onPatch` prop to make changes to the state - * before saving. + * Used to present the managed state of a `useSaveable` hook + * in the form of a save box. */ -export const SaveBox = ({ value, valid, onRevert, onPatch, onSave, onChangedState }: SaveBoxProps) => { - const [isSaving, setIsSaving] = useState(false); - const [original, setOriginal] = useState(klona(value)); +export function SaveBox({ handle, inline, position, saveText, revertText }: SaveBoxProps) { - const isChanged = useMemo(() => !fastDeepEqual(original, value), [original, value]); - - const doCompleteSave = useStable(() => { - setOriginal(klona(value)); - setIsSaving(false); - onSave?.(original); - }); - - const doRevert = useStable(() => { - onRevert?.(klona(original)); - }); - - const triggerSave = useLater(doCompleteSave); - - const doSave = useStable(async () => { - setIsSaving(true); - - await Promise.resolve(onPatch?.()); + const saveButton = ( + + ); - triggerSave(); - }); + const revertButton = ( + + ); - useEffect(() => { - if (onChangedState) { - onChangedState(isChanged); - } - }, [isChanged, onChangedState]); - return ( - - - {onRevert && ( - - )} - - ); -}; + if (inline) { + return ( + + {revertButton} + {saveButton} + + ); + } else { + return ( + + + } + styles={{ + icon: { + backgroundColor: 'transparent !important', + color: 'var(--mantine-color-surreal-5) !important', + }, + body: { + margin: 0 + } + }} + > + + There are unsaved changes + + {revertButton} + {saveButton} + + + + ); + } +} diff --git a/src/components/SaveBox/style.module.scss b/src/components/SaveBox/style.module.scss index ce308d186..343d8dea3 100644 --- a/src/components/SaveBox/style.module.scss +++ b/src/components/SaveBox/style.module.scss @@ -1,18 +1,29 @@ -.root { - width: 544px; - max-width: 100%; +.savebox { + position: fixed; - left: 0; - right: 0; - bottom: 32px; - margin-inline: auto; + bottom: 2rem; + width: 34rem; + max-width: 100%; transition: bottom 0.65s cubic-bezier(0.34, 1.56, 0.64, 1); - box-shadow: 0 22px 45px hsla(247, 19%, 25%, 0.35); - border: none; - z-index: 10; -} + box-shadow: 0 16px 38px hsla(240, 19%, 7%, 0.45); + z-index: 999; + + &-left { + left: 2rem; + } + + &-right { + right: 2rem; + } + + &-center { + margin-inline: auto; + left: 0; + right: 0; + } -.hidden { - pointer-events: none; - bottom: -68px; + &-hidden { + pointer-events: none; + bottom: -4rem; + } } \ No newline at end of file diff --git a/src/hooks/save.ts b/src/hooks/save.ts index 1629ab811..3439edcf8 100644 --- a/src/hooks/save.ts +++ b/src/hooks/save.ts @@ -1,52 +1,115 @@ -import React, { useState } from "react"; -import { SaveBox } from "~/components/SaveBox"; +import fastDeepEqual from "fast-deep-equal"; +import { klona } from "klona"; +import { useMemo, useState } from "react"; +import { useLater } from "./later"; import { useStable } from "./stable"; -interface SaveBoxOptions { - when?: boolean; +type Task = unknown | Promise; + +export interface SaveableOptions { + + /** + * The state object to track changes on + */ track: T; + + /** + * Whether the state is valid for saving + */ valid?: boolean; - onPatch?: () => void; - onSave: (original?: T) => void; - onRevert?: (original: T) => void; - onChangedState?: (value: boolean) => void; + + /** + * Called when the current state should be saved + * + * @param original The original state + */ + onSave: (original?: T) => Task; + + /** + * Called when the current state should be reverted + * + * @param original The original state + */ + onRevert: (original: T) => void; + } -interface SaveBoxResult { - render: JSX.Element | null; - skip: () => void; +export interface SaveableHandle { + + /** + * Whether the state is currently considered changed + */ + isChanged: boolean; + + /** + * Whether the state is currently valid for saving + */ + isSaveable: boolean; + + /** + * Whether the state is currently being saved + */ + isSaving: boolean; + + /** + * Forcefully refresh the internal state to + * to the current tracked state. + * + * @param value Optional manual value to refresh to + */ + refresh: (value?: T) => void; + + /** + * Save the current state + */ + save: () => Promise; + + /** + * Revert the current state + */ + revert: () => void; + } /** - * Helper hook to facilitate the rendering of a save box. The save box - * will only start tracking changes after the `when` condition is met. - * - * The skip function can be called directly after a mutation to tracked - * state in order to prevent the save box from revealing. + * The saveable hook provides facilities for tracking and reverting changes, + * saving state, and performing validation. * - * @param options The save box options - * @returns The save box element + * @param options The saveable options + * @returns The saveable handle */ -export function useSaveBox>(options: SaveBoxOptions): SaveBoxResult { - const showSaveBox = options.when ?? true; - const [skipKey, setSkipKey] = useState(0); +export function useSaveable>(options: SaveableOptions): SaveableHandle { + const [isSaving, setIsSaving] = useState(false); + const [original, setOriginal] = useState(klona(options.track)); + + const isChanged = useMemo(() => !fastDeepEqual(original, options.track), [original, options.track]); + const canSave = !isChanged || options.valid === false; + + const refresh = useStable((value?: T) => { + setOriginal(klona(value ?? options.track)); + }); + + const refreshLater = useLater(refresh); + + const save = useStable(async () => { + setIsSaving(true); - const skip = useStable(() => { - setSkipKey(k => k + 1); + await options.onSave(original); + + setIsSaving(false); + refreshLater(); }); - const render = showSaveBox ? React.createElement(SaveBox, { - key: skipKey, - value: options.track, - valid: options.valid, - onRevert: options.onRevert, - onSave: options.onSave, - onPatch: options.onPatch, - onChangedState: options.onChangedState, - }) : null; + const revert = useStable(() => { + options.onRevert(klona(original)); + }); return { - render, - skip, + isSaveable: canSave, + isChanged, + isSaving, + refresh, + save, + revert }; } diff --git a/src/views/designer/DesignPane/index.tsx b/src/views/designer/DesignPane/index.tsx index c6f582df4..021cac91e 100644 --- a/src/views/designer/DesignPane/index.tsx +++ b/src/views/designer/DesignPane/index.tsx @@ -13,10 +13,9 @@ import { } from "@mantine/core"; import { mdiClose, mdiDelete, mdiWrench } from "@mdi/js"; -import { MouseEvent, useMemo, useState } from "react"; +import { MouseEvent, useEffect, useMemo, useState } from "react"; import { useImmer } from "use-immer"; import { Panel } from "~/components/Panel"; -import { useSaveBox } from "~/hooks/save"; import { useStable } from "~/hooks/stable"; import { TableDefinition } from "~/types"; import { showError } from "~/util/helpers"; @@ -35,8 +34,10 @@ import { ModalTitle } from "~/components/ModalTitle"; import { ViewElement } from "./elements/view"; import { ChangefeedElement } from "./elements/changefeed"; import { getActiveSurreal, getSurreal } from "~/util/connection"; +import { useSaveable } from "~/hooks/save"; +import { SaveBox } from "~/components/SaveBox"; -const INITIAL_TABS = ["general", "view", "changefeed", "permissions", "fields", "indexes", "events"]; +const INITIAL_TABS = ["general"]; export interface SchemaPaneProps { table: TableDefinition; @@ -49,11 +50,10 @@ export function DesignPane(props: SchemaPaneProps) { const isShifting = useActiveKeys("Shift"); const isValid = data ? isSchemaValid(data) : true; const [isDeleting, setIsDeleting] = useState(false); - const [isChanged, setIsChanged] = useState(false); - const saveBox = useSaveBox({ - track: data!, + const handle = useSaveable({ valid: !!isValid, + track: data, onSave(original) { if (!original?.schema) { showError("Save failed", "Could not determine previous state"); @@ -71,10 +71,7 @@ export function DesignPane(props: SchemaPaneProps) { }, onRevert(original) { setData(original); - }, - onChangedState(value) { - setIsChanged(value); - }, + } }); const requestDelete = useStable((e: MouseEvent) => { @@ -106,14 +103,18 @@ export function DesignPane(props: SchemaPaneProps) { const isEdge = useMemo(() => isEdgeTable(props.table), [props.table]); + useEffect(() => { + setData(props.table); + handle.refresh(props.table); + }, [props.table]); + return ( Changes not yet applied + handle.isChanged && (isValid ? ( + Unsaved changes ) : ( Missing required fields )) @@ -158,7 +159,7 @@ export function DesignPane(props: SchemaPaneProps) { }, })} /> - + - - - {saveBox.render} - + + + + )}