Path: blob/master/src/packages/next/components/misc/save-button.tsx
5765 views
import { Alert, Button, Space } from "antd";1import { cloneDeep, debounce, isEqual } from "lodash";2import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";34import Loading from "components/share/loading";5import api from "lib/api/post";6import useIsMounted from "lib/hooks/mounted";78import { Icon } from "@cocalc/frontend/components/icon";9import { keys } from "@cocalc/util/misc";10import { SCHEMA } from "@cocalc/util/schema";1112interface Props {13edited: any;14original: any;15setOriginal: Function;16table?: string;17style?: CSSProperties;18onSave?: Function; // if onSave is async then awaits and if there is an error shows that; if not, updates state to what was saved.19isValid?: (object) => boolean; // if given, only allow saving if edited != original and isValid(edited) is true.20debounce_ms?: number; // default is DEBOUNCE_MS21disabled?: boolean; // if given, overrides internaal logic.22}2324const DEBOUNCE_MS = 1500;2526export default function SaveButton({27disabled,28edited,29original,30setOriginal,31table,32style,33onSave,34isValid,35debounce_ms,36}: Props) {37if (debounce_ms == null) debounce_ms = DEBOUNCE_MS;38const [saving, setSaving] = useState<boolean>(false);39const [error, setError] = useState<string>("");4041// Tricky hooks: We have to store the state in a ref as well so that42// we can use it in the save function, since that function43// is memoized and called from a debounced function.44const saveRef = useRef<any>({ edited, original, table });45saveRef.current = { edited, original, table };4647const isMounted = useIsMounted();4849const save = useMemo(() => {50return async () => {51const { edited, original, table } = saveRef.current;5253let changes: boolean = false;54const e: any = {};55for (const field in edited) {56if (!isEqual(original[field], edited[field])) {57e[field] = cloneDeep(edited[field]);58changes = true;59}60}61if (!changes) {62// no changes to save.63return false;64}6566for (const field of preserveFields(table)) {67e[field] = cloneDeep(edited[field]);68}69const query = { [table]: e };70if (isMounted.current) {71setSaving(true);72setError("");73}74let result;75try {76// Note -- we definitely do want to do the save77// itself, even if the component is already unmounted,78// so we don't loose changes.79result = await api("/user-query", { query });80} catch (err) {81if (!isMounted.current) return;82setError(err.message);83return false;84} finally {85if (isMounted.current) {86setSaving(false);87}88}89if (!isMounted.current) return;90if (result.error) {91setError(result.error);92} else {93setOriginal(cloneDeep(edited));94}95return true; // successful save96};97}, []);9899function doSave() {100(async () => {101const e = cloneDeep(saveRef.current.edited);102if (table) {103const didSave = await save();104if (!isMounted.current) return;105if (didSave) {106await onSave?.(e);107}108return;109}110try {111await onSave?.(e);112if (!isMounted.current) return;113setOriginal(e);114setError("");115} catch (err) {116setError(err.toString());117}118})();119}120121const doSaveDebounced = useMemo(122() => debounce(doSave, debounce_ms),123[onSave],124);125126useEffect(() => {127doSaveDebounced();128return doSaveDebounced;129}, [edited]);130131const same = isEqual(edited, original);132return (133<div style={style}>134<Button135type="primary"136disabled={137disabled ?? (saving || same || (isValid != null && !isValid(edited)))138}139onClick={doSave}140>141<Space>142<Icon name={"save"} />143{saving ? (144<Loading delay={250} before="Save">145Saving...146</Loading>147) : (148"Save"149)}150</Space>151</Button>152{!same && error && (153<Alert type="error" message={error} style={{ marginTop: "15px" }} />154)}155</div>156);157}158159function preserveFields(table: string): string[] {160return keys(SCHEMA[table].user_query?.set?.required_fields ?? {});161}162163164