Skip to content

Commit

Permalink
refactor: overhaul savebox logic
Browse files Browse the repository at this point in the history
closes #166
  • Loading branch information
macjuul committed Jan 24, 2024
1 parent 08030eb commit 4b99097
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 146 deletions.
159 changes: 79 additions & 80 deletions src/components/SaveBox/index.tsx
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>
);
}
}
39 changes: 25 additions & 14 deletions src/components/SaveBox/style.module.scss
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;
}
}
131 changes: 97 additions & 34 deletions src/hooks/save.ts
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
};
}
Loading

0 comments on commit 4b99097

Please sign in to comment.