-
Notifications
You must be signed in to change notification settings - Fork 84
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
closes #166
- Loading branch information
Showing
4 changed files
with
220 additions
and
146 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<any>; | ||
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 = ( | ||
<Button | ||
rightIcon={<Icon path={mdiCheck} size="md" />} | ||
loading={handle.isSaving} | ||
disabled={handle.isSaveable} | ||
onClick={handle.save} | ||
> | ||
{saveText ?? 'Save changes'} | ||
</Button> | ||
); | ||
|
||
triggerSave(); | ||
}); | ||
const revertButton = ( | ||
<Button | ||
disabled={handle.isSaveable} | ||
onClick={handle.revert} | ||
color="dark.4" | ||
> | ||
{revertText ?? 'Revert'} | ||
</Button> | ||
); | ||
|
||
useEffect(() => { | ||
if (onChangedState) { | ||
onChangedState(isChanged); | ||
} | ||
}, [isChanged, onChangedState]); | ||
|
||
return ( | ||
<Group spacing={10} align="center" position="apart"> | ||
<Button | ||
rightIcon={<Icon path={mdiCheck} size={1} />} | ||
loaderPosition="right" | ||
loading={isSaving} | ||
disabled={!isChanged || valid === false} | ||
onClick={doSave} | ||
> | ||
Save changes | ||
</Button> | ||
{onRevert && ( | ||
<Button disabled={!isChanged || valid === false} onClick={doRevert} color="dark.4"> | ||
Revert | ||
</Button> | ||
)} | ||
</Group> | ||
); | ||
}; | ||
if (inline) { | ||
return ( | ||
<Group spacing={10} align="center" position="apart"> | ||
{revertButton} | ||
{saveButton} | ||
</Group> | ||
); | ||
} else { | ||
return ( | ||
<Portal> | ||
<Notification | ||
withCloseButton={false} | ||
className={clsx( | ||
classes.savebox, | ||
classes[`savebox${capitalize(position ?? 'center')}`], | ||
!handle.isChanged && classes.saveboxHidden | ||
)} | ||
icon={ | ||
<Icon | ||
path={mdiInformationOutline} | ||
size="lg" | ||
mr={-8} | ||
/> | ||
} | ||
styles={{ | ||
icon: { | ||
backgroundColor: 'transparent !important', | ||
color: 'var(--mantine-color-surreal-5) !important', | ||
}, | ||
body: { | ||
margin: 0 | ||
} | ||
}} | ||
> | ||
<Group spacing={10} align="center"> | ||
There are unsaved changes | ||
<Spacer /> | ||
{revertButton} | ||
{saveButton} | ||
</Group> | ||
</Notification> | ||
</Portal> | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<T> { | ||
when?: boolean; | ||
type Task = unknown | Promise<unknown>; | ||
|
||
export interface SaveableOptions<T> { | ||
|
||
/** | ||
* 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<T> { | ||
|
||
/** | ||
* 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<void>; | ||
|
||
/** | ||
* 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<T extends Record<string, any>>(options: SaveBoxOptions<T>): SaveBoxResult { | ||
const showSaveBox = options.when ?? true; | ||
const [skipKey, setSkipKey] = useState(0); | ||
export function useSaveable<T extends Record<string, any>>(options: SaveableOptions<T>): SaveableHandle<T> { | ||
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 | ||
}; | ||
} |
Oops, something went wrong.