Merge pull request #1084 from hgiesel/svelte-hooks

Svelte "hooks" - Streamline method of getting async data
This commit is contained in:
Damien Elmes 2021-03-23 19:22:46 +10:00 committed by GitHub
commit 1bf19d581f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 212 additions and 103 deletions

View file

@ -20,6 +20,7 @@ ts_library(
"GraphsPage", "GraphsPage",
"lib", "lib",
"//ts/lib", "//ts/lib",
"//ts/sveltelib",
"@npm//svelte", "@npm//svelte",
"@npm//svelte2tsx", "@npm//svelte2tsx",
], ],
@ -32,6 +33,7 @@ ts_library(
exclude = ["index.ts"], exclude = ["index.ts"],
), ),
deps = [ deps = [
"//ts/sveltelib",
"//ts/lib", "//ts/lib",
"//ts/lib:backend_proto", "//ts/lib:backend_proto",
"@npm//@types/d3", "@npm//@types/d3",
@ -59,6 +61,7 @@ esbuild(
output_css = True, output_css = True,
visibility = ["//visibility:public"], visibility = ["//visibility:public"],
deps = [ deps = [
"//ts/sveltelib",
"//ts/lib", "//ts/lib",
"//ts/lib:backend_proto", "//ts/lib:backend_proto",
"//ts/lib:fluent_proto", "//ts/lib:fluent_proto",

View file

@ -43,6 +43,10 @@
display: none; display: none;
} }
} }
&:focus {
outline: 0;
}
} }
h1 { h1 {
@ -57,7 +61,7 @@
} }
</style> </style>
<div class="graph"> <div class="graph" tabindex="-1">
<h1>{title}</h1> <h1>{title}</h1>
{#if subtitle} {#if subtitle}

View file

@ -2,82 +2,49 @@
import "../sass/core.css"; import "../sass/core.css";
import type { SvelteComponent } from "svelte/internal"; import type { SvelteComponent } from "svelte/internal";
import { writable } from "svelte/store";
import type { I18n } from "anki/i18n"; import type { I18n } from "anki/i18n";
import type { PreferenceStore } from "./preferences";
import type pb from "anki/backend_proto";
import { getGraphData, RevlogRange, daysToRevlogRange } from "./graph-helpers";
import { getPreferences } from "./preferences";
import { bridgeCommand } from "anki/bridgecommand"; import { bridgeCommand } from "anki/bridgecommand";
import WithGraphData from "./WithGraphData.svelte";
export let i18n: I18n; export let i18n: I18n;
export let nightMode: boolean; export let nightMode: boolean;
export let graphs: SvelteComponent[]; export let graphs: SvelteComponent[];
export let search: string; export let initialSearch: string;
export let days: number; export let initialDays: number;
export let controller: SvelteComponent | null; export let controller: SvelteComponent | null;
let active = false; const search = writable(initialSearch);
let sourceData: pb.BackendProto.GraphsOut | null = null; const days = writable(initialDays);
let preferences: PreferenceStore | null = null;
let revlogRange: RevlogRange;
const preferencesPromise = getPreferences(); function browserSearch(event: CustomEvent) {
bridgeCommand(`browserSearch: ${$search} ${event.detail.query}`);
const refreshWith = async (searchNew: string, days: number) => {
search = searchNew;
active = true;
try {
[sourceData, preferences] = await Promise.all([
getGraphData(search, days),
preferencesPromise,
]);
revlogRange = daysToRevlogRange(days);
} catch (e) {
sourceData = null;
alert(e);
} }
active = false;
};
const refresh = (event: CustomEvent) => {
refreshWith(event.detail.search, event.detail.days);
};
refreshWith(search, days);
const browserSearch = (event: CustomEvent) => {
const query = `${search} ${event.detail.query}`;
bridgeCommand(`browserSearch:${query}`);
};
</script> </script>
<style lang="scss"> <style lang="scss">
div {
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.base {
font-size: 12px; font-size: 12px;
} }
} }
.no-focus-outline:focus {
outline: 0;
}
</style> </style>
<div class="base"> <div>
{#if controller} <WithGraphData
<svelte:component
this={controller}
{i18n}
{search} {search}
{days} {days}
{active} let:loading
on:update={refresh} /> let:sourceData
let:preferences
let:revlogRange>
{#if controller}
<svelte:component this={controller} {i18n} {search} {days} {loading} />
{/if} {/if}
{#if sourceData} {#if sourceData && preferences && revlogRange}
<div tabindex="-1" class="no-focus-outline">
{#each graphs as graph} {#each graphs as graph}
<svelte:component <svelte:component
this={graph} this={graph}
@ -88,6 +55,6 @@
{nightMode} {nightMode}
on:search={browserSearch} /> on:search={browserSearch} />
{/each} {/each}
</div>
{/if} {/if}
</WithGraphData>
</div> </div>

View file

@ -1,5 +1,5 @@
<script lang="typescript"> <script lang="typescript">
import { createEventDispatcher } from "svelte"; import type { Writable } from "svelte/store";
import InputBox from "./InputBox.svelte"; import InputBox from "./InputBox.svelte";
@ -12,45 +12,29 @@
Custom = 3, Custom = 3,
} }
type UpdateEventMap = {
update: { days: number; search: string; searchRange: SearchRange };
};
export let i18n: I18n; export let i18n: I18n;
export let active: boolean; export let loading: boolean;
export let days: number; export let days: Writable<number>;
export let search: string; export let search: Writable<string>;
const dispatch = createEventDispatcher<UpdateEventMap>(); let revlogRange = daysToRevlogRange($days);
let searchRange =
let revlogRange = daysToRevlogRange(days); $search === "deck:current"
let searchRange: SearchRange =
search === "deck:current"
? SearchRange.Deck ? SearchRange.Deck
: search === "" : $search === ""
? SearchRange.Collection ? SearchRange.Collection
: SearchRange.Custom; : SearchRange.Custom;
let displayedSearch = search; let displayedSearch = $search;
const update = () => {
dispatch("update", {
days: days,
search: search,
searchRange: searchRange,
});
};
$: { $: {
switch (searchRange as SearchRange) { switch (searchRange as SearchRange) {
case SearchRange.Deck: case SearchRange.Deck:
search = displayedSearch = "deck:current"; $search = displayedSearch = "deck:current";
update();
break; break;
case SearchRange.Collection: case SearchRange.Collection:
search = displayedSearch = ""; $search = displayedSearch = "";
update();
break; break;
} }
} }
@ -58,23 +42,20 @@
$: { $: {
switch (revlogRange as RevlogRange) { switch (revlogRange as RevlogRange) {
case RevlogRange.Year: case RevlogRange.Year:
days = 365; $days = 365;
update();
break; break;
case RevlogRange.All: case RevlogRange.All:
days = 0; $days = 0;
update();
break; break;
} }
} }
const searchKeyUp = (e: KeyboardEvent) => { function searchKeyUp(event: KeyboardEvent): void {
// fetch data on enter // fetch data on enter
if (e.key == "Enter") { if (event.code === "Enter") {
search = displayedSearch; $search = displayedSearch;
update(); }
} }
};
const year = i18n.tr(i18n.TR.STATISTICS_RANGE_1_YEAR_HISTORY); const year = i18n.tr(i18n.TR.STATISTICS_RANGE_1_YEAR_HISTORY);
const deck = i18n.tr(i18n.TR.STATISTICS_RANGE_DECK); const deck = i18n.tr(i18n.TR.STATISTICS_RANGE_DECK);
@ -117,7 +98,7 @@
opacity: 0; opacity: 0;
&.active { &.loading {
opacity: 0.5; opacity: 0.5;
transition: opacity 1s; transition: opacity 1s;
} }
@ -129,7 +110,7 @@
</style> </style>
<div class="range-box"> <div class="range-box">
<div class="spin" class:active></div> <div class="spin" class:loading></div>
<InputBox> <InputBox>
<label> <label>

View file

@ -10,7 +10,7 @@
let container = (null as unknown) as HTMLDivElement; let container = (null as unknown) as HTMLDivElement;
let adjustedX, adjustedY: number; let adjustedX: number, adjustedY: number;
let shiftLeftAmount = 0; let shiftLeftAmount = 0;
$: shiftLeftAmount = container $: shiftLeftAmount = container

View file

@ -0,0 +1,44 @@
<script lang="typescript">
import type { Writable } from "svelte/store";
import useAsync from "sveltelib/async";
import useAsyncReactive from "sveltelib/asyncReactive";
import { getGraphData, daysToRevlogRange } from "./graph-helpers";
import { getPreferences } from "./preferences";
export let search: Writable<string>;
export let days: Writable<number>;
const {
loading: graphLoading,
error: graphError,
value: graphValue,
} = useAsyncReactive(() => getGraphData($search, $days), [search, days]);
const {
loading: prefsLoading,
error: prefsError,
value: prefsValue,
} = useAsync(() => getPreferences());
$: revlogRange = daysToRevlogRange($days);
$: {
if ($graphError) {
alert($graphError);
}
}
$: {
if ($prefsError) {
alert($prefsError);
}
}
</script>
<slot
{revlogRange}
loading={$graphLoading || $prefsLoading}
sourceData={$graphValue}
preferences={$prefsValue} />

View file

@ -40,8 +40,8 @@ export function graphs(
i18n, i18n,
graphs, graphs,
nightMode, nightMode,
search, initialSearch: search,
days, initialDays: days,
controller, controller,
}, },
}); });

View file

@ -67,6 +67,7 @@ def svelte_check(name = "svelte_check", srcs = []):
], ],
data = [ data = [
"//ts:tsconfig.json", "//ts:tsconfig.json",
"//ts/sveltelib",
"//ts/lib", "//ts/lib",
"//ts/lib:backend_proto", "//ts/lib:backend_proto",
"@npm//sass", "@npm//sass",

32
ts/sveltelib/BUILD.bazel Normal file
View file

@ -0,0 +1,32 @@
load("@npm//@bazel/typescript:index.bzl", "ts_library")
load("//ts:prettier.bzl", "prettier_test")
load("//ts:eslint.bzl", "eslint_test")
# Anki Library
################
ts_library(
name = "sveltelib",
srcs = glob(["**/*.ts"]),
data = [],
module_name = "sveltelib",
tsconfig = "//:tsconfig.json",
visibility = ["//visibility:public"],
deps = [
"@npm//svelte",
"@npm//tslib",
],
)
# Tests
################
prettier_test(
name = "format_check",
srcs = glob(["*.ts"]),
)
eslint_test(
name = "eslint",
srcs = glob(["*.ts"]),
)

27
ts/sveltelib/async.ts Normal file
View file

@ -0,0 +1,27 @@
import { Readable, readable } from "svelte/store";
interface AsyncData<T, E> {
value: Readable<T | null>;
error: Readable<E | null>;
loading: Readable<boolean>;
}
function useAsync<T, E = unknown>(asyncFunction: () => Promise<T>): AsyncData<T, E> {
const promise = asyncFunction();
const value = readable(null, (set: (value: T) => void) => {
promise.then((value: T) => set(value));
});
const error = readable(null, (set: (value: E) => void) => {
promise.catch((value: E) => set(value));
});
const loading = readable(true, (set: (value: boolean) => void) => {
promise.finally(() => set(false));
});
return { value, error, loading };
}
export default useAsync;

View file

@ -0,0 +1,49 @@
import { Readable, derived } from "svelte/store";
interface AsyncReativeData<T, E> {
value: Readable<T | null>;
error: Readable<E | null>;
loading: Readable<boolean>;
}
function useAsyncReactive<T, E>(
asyncFunction: () => Promise<T>,
dependencies: [Readable<unknown>, ...Readable<unknown>[]]
): AsyncReativeData<T, E> {
const promise = derived(
dependencies,
(_, set: (value: Promise<T> | null) => void): void => set(asyncFunction()),
// initialize with null to avoid duplicate fetch on init
null
);
const value = derived(
promise,
($promise, set: (value: T) => void): void => {
$promise?.then((value: T) => set(value));
},
null
);
const error = derived(
promise,
($promise, set: (error: E | null) => void): (() => void) => {
$promise?.catch((error: E) => set(error));
return (): void => set(null);
},
null
);
const loading = derived(
promise,
($promise, set: (value: boolean) => void): (() => void) => {
$promise?.finally(() => set(false));
return (): void => set(true);
},
true
);
return { value, error, loading };
}
export default useAsyncReactive;

View file

@ -3,10 +3,11 @@
"compilerOptions": { "compilerOptions": {
"target": "es6", "target": "es6",
"module": "es6", "module": "es6",
"lib": ["es2017", "es2019.array", "dom", "dom.iterable"], "lib": ["es2017", "es2019.array", "es2018.promise", "dom", "dom.iterable"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"anki/*": ["../bazel-bin/ts/lib/*"] "anki/*": ["../bazel-bin/ts/lib/*"],
"sveltelib/*": ["../bazel-bin/ts/sveltelib/*"]
}, },
"importsNotUsedAsValues": "error", "importsNotUsedAsValues": "error",
"outDir": "dist", "outDir": "dist",