Merge pull request #4897 from urbit/lf/optimistic-notif

interface: optimistic updating
This commit is contained in:
matildepark 2021-05-13 09:56:48 -04:00 committed by GitHub
commit b77b83e446
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 180 additions and 129 deletions

View File

@ -6643,9 +6643,9 @@
"dev": true "dev": true
}, },
"immer": { "immer": {
"version": "8.0.1", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.2.tgz",
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==" "integrity": "sha512-mkcmzLtIfSp40vAqteRr1MbWNSoI7JE+/PB36FNPoSfJ9RQRmNKuTYCjKkyXyuq3Dgn07HuJBrwJd4ZSk2yUbw=="
}, },
"import-fresh": { "import-fresh": {
"version": "3.3.0", "version": "3.3.0",
@ -11313,6 +11313,7 @@
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-extendable": "^0.1.0" "is-extendable": "^0.1.0"
} }
@ -11379,6 +11380,7 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"kind-of": "^3.0.2" "kind-of": "^3.0.2"
} }
@ -12195,9 +12197,9 @@
} }
}, },
"zustand": { "zustand": {
"version": "3.3.1", "version": "3.5.1",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.3.1.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.5.1.tgz",
"integrity": "sha512-o0rgrBsi29nCkPHdhtkAHisCIlmRUoXOV+1AmDMeCgkGG0i5edFSpGU0KiZYBvFmBYycnck4Z07JsLYDjSET9g==" "integrity": "sha512-7J56Ve814z4zap71iaKFD+t65LFI//jEq/Vf55BTSVqJZCm+w9rov8OMBg+YSwIPQk54bfoIWHTrOWuAbpEDMw=="
} }
} }
} }

View File

@ -22,7 +22,7 @@
"css-loader": "^3.6.0", "css-loader": "^3.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"formik": "^2.1.5", "formik": "^2.1.5",
"immer": "^8.0.1", "immer": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.1", "moment": "^2.29.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
@ -56,7 +56,7 @@
"workbox-recipes": "^6.0.2", "workbox-recipes": "^6.0.2",
"workbox-routing": "^6.0.2", "workbox-routing": "^6.0.2",
"yup": "^0.29.3", "yup": "^0.29.3",
"zustand": "^3.3.1" "zustand": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",

View File

@ -1,6 +1,9 @@
import { Patp } from '@urbit/api'; import { Patp } from '@urbit/api';
import { ContactEditField } from '@urbit/api/contacts'; import { ContactEditField } from '@urbit/api/contacts';
import _ from 'lodash'; import _ from 'lodash';
import {edit} from '../reducers/contact-update';
import {doOptimistically} from '../state/base';
import useContactState from '../state/contact';
import { StoreState } from '../store/type'; import { StoreState } from '../store/type';
import BaseApi from './base'; import BaseApi from './base';
@ -26,13 +29,14 @@ export default class ContactsApi extends BaseApi<StoreState> {
{add-group: {ship, name}} {add-group: {ship, name}}
{remove-group: {ship, name}} {remove-group: {ship, name}}
*/ */
return this.storeAction({ const action = {
edit: { edit: {
ship, ship,
'edit-field': editField, 'edit-field': editField,
timestamp: Date.now() timestamp: Date.now()
} }
}); }
doOptimistically(useContactState, action, this.storeAction.bind(this), [edit])
} }
allowShips(ships: Patp[]) { allowShips(ships: Patp[]) {

View File

@ -1,7 +1,10 @@
import { Association, GraphNotifDescription, IndexedNotification, NotifIndex } from '@urbit/api'; import { Association, GraphNotifDescription, IndexedNotification, NotifIndex } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification'; import { getParentIndex } from '../lib/notification';
import { dateToDa, decToUd } from '../lib/util'; import { dateToDa, decToUd } from '../lib/util';
import {reduce} from '../reducers/hark-update';
import {doOptimistically, optReduceState} from '../state/base';
import useHarkState from '../state/hark'; import useHarkState from '../state/hark';
import { StoreState } from '../store/type'; import { StoreState } from '../store/type';
import BaseApi from './base'; import BaseApi from './base';
@ -51,8 +54,15 @@ export class HarkApi extends BaseApi<StoreState> {
}); });
} }
archive(time: BigInteger, index: NotifIndex) { async archive(intTime: BigInteger, index: NotifIndex) {
return this.actOnNotification('archive', time, index); const time = decToUd(intTime.toString());
const action = {
archive: {
time,
index
}
};
await doOptimistically(useHarkState, action, this.harkAction.bind(this), [reduce])
} }
read(time: BigInteger, index: NotifIndex) { read(time: BigInteger, index: NotifIndex) {

View File

@ -1,4 +1,4 @@
import { ContactUpdate } from '@urbit/api'; import { ContactUpdate, deSig } from '@urbit/api';
import _ from 'lodash'; import _ from 'lodash';
import { reduceState } from '../state/base'; import { reduceState } from '../state/base';
import useContactState, { ContactState } from '../state/contact'; import useContactState, { ContactState } from '../state/contact';
@ -52,9 +52,9 @@ const remove = (json: ContactUpdate, state: ContactState): ContactState => {
return state; return state;
}; };
const edit = (json: ContactUpdate, state: ContactState): ContactState => { export const edit = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'edit', false); const data = _.get(json, 'edit', false);
const ship = `~${data.ship}`; const ship = `~${deSig(data.ship)}`;
if ( if (
data && data &&
(ship in state.contacts) (ship in state.contacts)

View File

@ -35,7 +35,7 @@ export const HarkReducer = (json: any) => {
} }
}; };
function reduce(data, state) { export function reduce(data, state) {
const reducers = [ const reducers = [
calculateCount, calculateCount,
unread, unread,

View File

@ -1,24 +1,39 @@
import produce, { setAutoFreeze } from 'immer'; import produce, { applyPatches, Patch, produceWithPatches, setAutoFreeze, enablePatches } from 'immer';
import { compose } from 'lodash/fp'; import { compose } from 'lodash/fp';
import create, { State, UseStore } from 'zustand'; import _ from 'lodash';
import create, { UseStore } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
setAutoFreeze(false); setAutoFreeze(false);
enablePatches();
export const stateSetter = <StateType>( export const stateSetter = <T extends {}>(
fn: (state: StateType) => void, fn: (state: Readonly<T & BaseState<T>>) => void,
set set: (newState: T & BaseState<T>) => void
): void => { ): void => {
set(produce(fn)); set(produce(fn) as any);
}; };
export const optStateSetter = <T extends {}>(
fn: (state: T & BaseState<T>) => void,
set: (newState: T & BaseState<T>) => void,
get: () => T & BaseState<T>
): string => {
const old = get();
const id = _.uniqueId()
const [state, ,patches] = produceWithPatches(old, fn) as readonly [(T & BaseState<T>), any, Patch[]];
set({ ...state, patches: { ...state.patches, [id]: patches }});
return id;
};
export const reduceState = < export const reduceState = <
StateType extends BaseState<StateType>, S extends {},
UpdateType U
>( >(
state: UseStore<StateType>, state: UseStore<S & BaseState<S>>,
data: UpdateType, data: U,
reducers: ((data: UpdateType, state: StateType) => StateType)[] reducers: ((data: U, state: S & BaseState<S>) => S & BaseState<S>)[]
): void => { ): void => {
const reducer = compose(reducers.map(r => sta => r(data, sta))); const reducer = compose(reducers.map(r => sta => r(data, sta)));
state.getState().set((state) => { state.getState().set((state) => {
@ -26,6 +41,18 @@ export const reduceState = <
}); });
}; };
export const optReduceState = <S, U>(
state: UseStore<S & BaseState<S>>,
data: U,
reducers: ((data: U, state: S & BaseState<S>) => BaseState<S> & S)[]
): string => {
const reducer = compose(reducers.map(r => sta => r(data, sta)));
return state.getState().optSet((state) => {
reducer(state);
});
};
export let stateStorageKeys: string[] = []; export let stateStorageKeys: string[] = [];
export const stateStorageKey = (stateName: string) => { export const stateStorageKey = (stateName: string) => {
@ -40,19 +67,56 @@ export const stateStorageKey = (stateName: string) => {
}); });
}; };
export interface BaseState<StateType> extends State { export interface BaseState<StateType> {
set: (fn: (state: StateType) => void) => void; rollback: (id: string) => void;
patches: {
[id: string]: Patch[];
};
set: (fn: (state: BaseState<StateType>) => void) => void;
addPatch: (id: string, ...patch: Patch[]) => void;
removePatch: (id: string) => void;
optSet: (fn: (state: BaseState<StateType>) => void) => string;
} }
export const createState = <T extends BaseState<T>>( export const createState = <T extends {}>(
name: string, name: string,
properties: { [K in keyof Omit<T, 'set'>]: T[K] }, properties: T,
blacklist: string[] = [] blacklist: (keyof BaseState<T> | keyof T)[] = []
): UseStore<T> => create(persist((set, get) => ({ ): UseStore<T & BaseState<T>> => create<T & BaseState<T>>(persist<T & BaseState<T>>((set, get) => ({
set: fn => stateSetter(fn, set), set: fn => stateSetter(fn, set),
...properties as any optSet: fn => {
return optStateSetter(fn, set, get);
},
patches: {},
addPatch: (id: string, ...patch: Patch[]) => {
set(({ patches }) => ({ patches: {...patches, [id]: patch }}));
},
removePatch: (id: string) => {
set(({ patches }) => ({ patches: _.omit(patches, id)}));
},
rollback: (id: string) => {
set(state => {
const applying = state.patches[id]
return {...applyPatches(state, applying), patches: _.omit(state.patches, id) }
});
},
...properties
}), { }), {
blacklist, blacklist,
name: stateStorageKey(name), name: stateStorageKey(name),
version: process.env.LANDSCAPE_SHORTHASH as any version: process.env.LANDSCAPE_SHORTHASH as any
})); }));
export async function doOptimistically<A, S extends {}>(state: UseStore<S & BaseState<S>>, action: A, call: (a: A) => Promise<any>, reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]) {
let num: string | undefined = undefined;
try {
num = optReduceState(state, action, reduce);
await call(action);
state.getState().removePatch(num)
} catch (e) {
console.error(e);
if(num) {
state.getState().rollback(num);
}
}
}

View File

@ -1,11 +1,11 @@
import { NotificationGraphConfig, Timebox, Unreads } from '@urbit/api'; import { NotificationGraphConfig, Timebox, Unreads } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap'; import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
// import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark"; // import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark";
import { BaseState, createState } from './base'; import { createState } from './base';
export const HARK_FETCH_MORE_COUNT = 3; export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState extends BaseState<HarkState> { export interface HarkState {
archivedNotifications: BigIntOrderedMap<Timebox>; archivedNotifications: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean; doNotDisturb: boolean;
// getMore: () => Promise<boolean>; // getMore: () => Promise<boolean>;

View File

@ -90,11 +90,12 @@ export function EditProfile(props: any): ReactElement {
const onSubmit = async (values: any, actions: any) => { const onSubmit = async (values: any, actions: any) => {
try { try {
await Object.keys(values).reduce((acc, key) => { Object.keys(values).forEach((key) => {
const newValue = key !== 'color' ? values[key] : uxToHex(values[key]); const newValue = key !== 'color' ? values[key] : uxToHex(values[key]);
if (newValue !== contact[key]) { if (newValue !== contact[key]) {
if (key === 'isPublic') { if (key === 'isPublic') {
return acc.then(() => api.contacts.setPublic(newValue)); api.contacts.setPublic(newValue)
return;
} else if (key === 'groups') { } else if (key === 'groups') {
const toRemove: string[] = _.difference( const toRemove: string[] = _.difference(
contact?.groups || [], contact?.groups || [],
@ -104,24 +105,18 @@ export function EditProfile(props: any): ReactElement {
newValue, newValue,
contact?.groups || [] contact?.groups || []
); );
const promises: Promise<any>[] = []; toRemove.forEach(e =>
promises.concat(
toRemove.map(e =>
api.contacts.edit(ship, { 'remove-group': resourceFromPath(e) }) api.contacts.edit(ship, { 'remove-group': resourceFromPath(e) })
) )
); toAdd.forEach(e =>
promises.concat(
toAdd.map(e =>
api.contacts.edit(ship, { 'add-group': resourceFromPath(e) }) api.contacts.edit(ship, { 'add-group': resourceFromPath(e) })
) )
);
return acc.then(() => Promise.all(promises));
} else if (key !== 'last-updated' && key !== 'isPublic') { } else if (key !== 'last-updated' && key !== 'isPublic') {
return acc.then(() => api.contacts.edit(ship, { [key]: newValue })); api.contacts.edit(ship, { [key]: newValue });
return;
} }
} }
return acc; });
}, Promise.resolve());
// actions.setStatus({ success: null }); // actions.setStatus({ success: null });
history.push(`/~profile/${ship}`); history.push(`/~profile/${ship}`);
} catch (e) { } catch (e) {

View File

@ -3,6 +3,7 @@ import {
ManagedRadioButtonField as Radio, Row, Text ManagedRadioButtonField as Radio, Row, Text
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import {useField} from 'formik';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { ColorInput } from '~/views/components/ColorInput'; import { ColorInput } from '~/views/components/ColorInput';
@ -10,11 +11,7 @@ import { ImageInput } from '~/views/components/ImageInput';
export type BgType = 'none' | 'url' | 'color'; export type BgType = 'none' | 'url' | 'color';
export function BackgroundPicker({ export function BackgroundPicker({ api }: {
bgType,
bgUrl,
api
}: {
bgType: BgType; bgType: BgType;
bgUrl?: string; bgUrl?: string;
api: GlobalApi; api: GlobalApi;
@ -40,7 +37,6 @@ export function BackgroundPicker({
id="bgUrl" id="bgUrl"
placeholder="Drop or upload a file, or paste a link here" placeholder="Drop or upload a file, or paste a link here"
name="bgUrl" name="bgUrl"
url={bgUrl || ''}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -6,9 +6,11 @@ import {
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings'; import useSettingsState, { selectSettingsState, SettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import _ from 'lodash';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
interface FormSchema { interface FormSchema {
hideAvatars: boolean; hideAvatars: boolean;
@ -22,57 +24,39 @@ interface FormSchema {
videoShown: boolean; videoShown: boolean;
} }
const settingsSel = selectSettingsState(['calm', 'remoteContentPolicy']); const settingsSel = (s: SettingsState): FormSchema => ({
hideAvatars: s.calm.hideAvatars,
hideNicknames: s.calm.hideAvatars,
hideUnreads: s.calm.hideUnreads,
hideGroups: s.calm.hideGroups,
hideUtilities: s.calm.hideUtilities,
imageShown: !s.remoteContentPolicy.imageShown,
videoShown: !s.remoteContentPolicy.videoShown,
oembedShown: !s.remoteContentPolicy.oembedShown,
audioShown: !s.remoteContentPolicy.audioShown
});
export function CalmPrefs(props: { export function CalmPrefs(props: {
api: GlobalApi; api: GlobalApi;
}) { }) {
const { api } = props; const { api } = props;
const { const initialValues = useSettingsState(settingsSel);
calm: {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities
},
remoteContentPolicy: {
imageShown,
videoShown,
oembedShown,
audioShown
}
} = useSettingsState(settingsSel);
const initialValues: FormSchema = {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities,
imageShown: !imageShown,
videoShown: !videoShown,
oembedShown: !oembedShown,
audioShown: !audioShown
};
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => { const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
await Promise.all([ let promises: Promise<any>[] = [];
api.settings.putEntry('calm', 'hideAvatars', v.hideAvatars), _.forEach(v, (bool, key) => {
api.settings.putEntry('calm', 'hideNicknames', v.hideNicknames), const bucket = ['imageShown', 'videoShown', 'audioShown', 'oembedShown'].includes(key) ? 'remoteContentPolicy' : 'calm';
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads), if(initialValues[key] !== bool) {
api.settings.putEntry('calm', 'hideGroups', v.hideGroups), promises.push(api.settings.putEntry(bucket, key, bool));
api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities), }
api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown), })
api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown), await Promise.all(promises);
api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown)
]);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
}, [api]); }, [api]);
return ( return (
<Formik initialValues={initialValues} onSubmit={onSubmit}> <FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form> <Form>
<BackButton /> <BackButton />
<Col borderBottom={1} borderBottomColor="washedGray" p={5} pt={4} gapY={5}> <Col borderBottom={1} borderBottomColor="washedGray" p={5} pt={4} gapY={5}>
@ -132,12 +116,8 @@ export function CalmPrefs(props: {
id="oembedShown" id="oembedShown"
caption="Embedded content may contain scripts that can track you" caption="Embedded content may contain scripts that can track you"
/> />
<AsyncButton primary width="fit-content" type="submit">
Save
</AsyncButton>
</Col> </Col>
</Form> </Form>
</Formik> </FormikOnBlur>
); );
} }

View File

@ -11,6 +11,7 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings'; import useSettingsState, { selectSettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { BackgroundPicker, BgType } from './BackgroundPicker'; import { BackgroundPicker, BgType } from './BackgroundPicker';
@ -58,7 +59,7 @@ export default function DisplayForm(props: DisplayFormProps) {
const bgType = backgroundType || 'none'; const bgType = backgroundType || 'none';
return ( return (
<Formik <FormikOnBlur
validationSchema={formSchema} validationSchema={formSchema}
initialValues={ initialValues={
{ {
@ -86,7 +87,6 @@ export default function DisplayForm(props: DisplayFormProps) {
actions.setStatus({ success: null }); actions.setStatus({ success: null });
}} }}
> >
{props => (
<Form> <Form>
<BackButton /> <BackButton />
<Col p={5} pt={4} gapY={5}> <Col p={5} pt={4} gapY={5}>
@ -98,10 +98,7 @@ export default function DisplayForm(props: DisplayFormProps) {
Customize visual interfaces across your Landscape Customize visual interfaces across your Landscape
</Text> </Text>
</Col> </Col>
<BackgroundPicker <BackgroundPicker api={api}
bgType={props.values.bgType}
bgUrl={props.values.bgUrl}
api={api}
/> />
<Label>Theme</Label> <Label>Theme</Label>
<Radio name="theme" id="light" label="Light" /> <Radio name="theme" id="light" label="Light" />
@ -112,7 +109,6 @@ export default function DisplayForm(props: DisplayFormProps) {
</AsyncButton> </AsyncButton>
</Col> </Col>
</Form> </Form>
)} </FormikOnBlur>
</Formik>
); );
} }

View File

@ -3,7 +3,8 @@ import {
Center, Col, Icon, Center, Col, Icon,
StatelessToggleSwitchField, Text ToggleSwitch, Text,
StatelessToggleSwitchField
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { Association, GraphConfig, resourceFromPath } from '@urbit/api'; import { Association, GraphConfig, resourceFromPath } from '@urbit/api';
import { useField } from 'formik'; import { useField } from 'formik';
@ -100,7 +101,7 @@ function Channel(props: { association: Association }) {
return isWatching(config, association.resource); return isWatching(config, association.resource);
}); });
const [{ value }, meta, { setValue }] = useField( const [{ value }, meta, { setValue, setTouched }] = useField(
`graph["${association.resource}"]` `graph["${association.resource}"]`
); );
@ -108,9 +109,11 @@ function Channel(props: { association: Association }) {
setValue(watching); setValue(watching);
}, [watching]); }, [watching]);
const onChange = () => { const onClick = () => {
setValue(!value); setValue(!value);
}; setTouched(true);
}
const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule); const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule);
@ -123,7 +126,7 @@ function Channel(props: { association: Association }) {
<Text> {metadata.title}</Text> <Text> {metadata.title}</Text>
</Box> </Box>
<Box gridColumn={4}> <Box gridColumn={4}>
<StatelessToggleSwitchField selected={value} onChange={onChange} /> <StatelessToggleSwitchField selected={value} onClick={onClick} />
</Box> </Box>
</> </>
); );

View File

@ -50,7 +50,6 @@ export function LeapSettings(props: { api: GlobalApi; }) {
const { leap, set: setSettingsState } = useSettingsState(settingsSel); const { leap, set: setSettingsState } = useSettingsState(settingsSel);
const categories = leap.categories as LeapCategories[]; const categories = leap.categories as LeapCategories[];
const missing = _.difference(leapCategories, categories); const missing = _.difference(leapCategories, categories);
console.log(categories);
const initialValues = { const initialValues = {
categories: [ categories: [

View File

@ -10,6 +10,7 @@ import GlobalApi from '~/logic/api/global';
import { isWatching } from '~/logic/lib/hark'; import { isWatching } from '~/logic/lib/hark';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { GroupChannelPicker } from './GroupChannelPicker'; import { GroupChannelPicker } from './GroupChannelPicker';
@ -82,7 +83,7 @@ export function NotificationPreferences(props: {
messaging messaging
</Text> </Text>
</Col> </Col>
<Formik initialValues={initialValues} onSubmit={onSubmit}> <FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form> <Form>
<Col gapY={4}> <Col gapY={4}>
<Toggle <Toggle
@ -109,12 +110,9 @@ export function NotificationPreferences(props: {
</Text> </Text>
<GroupChannelPicker /> <GroupChannelPicker />
</Col> </Col>
<AsyncButton primary width="fit-content">
Save
</AsyncButton>
</Col> </Col>
</Form> </Form>
</Formik> </FormikOnBlur>
</Col> </Col>
</> </>
); );

View File

@ -1,28 +1,32 @@
import { FormikConfig, FormikProvider, FormikValues, useFormik } from 'formik'; import { FormikConfig, FormikProvider, FormikValues, useFormik } from 'formik';
import React, { useEffect, useImperativeHandle } from 'react'; import React, { useEffect, useImperativeHandle, useState } from 'react';
export function FormikOnBlur< export function FormikOnBlur<
Values extends FormikValues = FormikValues, Values extends FormikValues = FormikValues,
ExtraProps = {} ExtraProps = {}
>(props: FormikConfig<Values> & ExtraProps) { >(props: FormikConfig<Values> & ExtraProps) {
const formikBag = useFormik<Values>({ ...props, validateOnBlur: true }); const formikBag = useFormik<Values>({ ...props, validateOnBlur: true });
const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if ( if (
Object.keys(formikBag.errors || {}).length === 0 && Object.keys(formikBag.errors || {}).length === 0 &&
Object.keys(formikBag.touched || {}).length !== 0 && formikBag.dirty &&
!formikBag.isSubmitting !formikBag.isSubmitting &&
!submitting
) { ) {
setSubmitting(true);
const { values } = formikBag; const { values } = formikBag;
formikBag.submitForm().then(() => { formikBag.submitForm().then(() => {
formikBag.resetForm({ values, touched: {} }); formikBag.resetForm({ values })
setSubmitting(false);
}); });
} }
}, [ }, [
formikBag.errors, formikBag.errors,
formikBag.touched, formikBag.dirty,
formikBag.submitForm, submitting,
formikBag.values formikBag.isSubmitting
]); ]);
const { children, innerRef } = props; const { children, innerRef } = props;