more experimental updates to deck config screen

- try out bootstrap modals - they're not perfect, but let's see how
they go for now. Won't be hard to switch to bridge commands if required.
- handle adding/renaming/removing
- add a class to manage the state
This commit is contained in:
Damien Elmes 2021-04-16 23:29:21 +10:00
parent a6ed8e90ce
commit c3fc07ac20
15 changed files with 442 additions and 109 deletions

View File

@ -5,10 +5,11 @@ load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte", "svelte_check")
load("//ts:esbuild.bzl", "esbuild")
load("//ts:vendor.bzl", "copy_bootstrap_icons")
load("//ts:compile_sass.bzl", "compile_sass")
load("@npm//jest-cli:index.bzl", "jest_test")
compile_sass(
group = "base_css",
srcs = ["deckconfig-base.scss"],
group = "base_css",
visibility = ["//visibility:public"],
deps = [
"//ts/sass:base_lib",
@ -24,6 +25,9 @@ svelte_names = [f.replace(".svelte", "") for f in svelte_files]
compile_svelte(
name = "svelte",
srcs = svelte_files,
deps = [
"@npm//@types/bootstrap",
],
)
copy_bootstrap_icons(
@ -50,12 +54,16 @@ ts_library(
"icons.ts",
"lib.ts",
"steps.ts",
"textInputModal.ts",
],
module_name = "deckconfig",
deps = [
"TextInputModal",
"//ts:image_module_support",
"//ts/lib",
"//ts/lib:backend_proto",
"@npm//lodash-es",
"@npm//svelte",
],
)
@ -112,5 +120,40 @@ svelte_check(
srcs = glob([
"*.ts",
"*.svelte",
]),
]) + [
"@npm//@types/bootstrap",
],
)
ts_library(
name = "test_lib",
srcs = glob(["*.test.ts"]),
tsconfig = "//ts:tsconfig.json",
deps = [
":lib",
"//ts/lib:backend_proto",
"@npm//@types/jest",
],
)
jest_test(
name = "test",
args = [
"--no-cache",
"--no-watchman",
"--ci",
"--colors",
"--config",
"$(location //ts:jest.config.js)",
],
data = [
":test_lib",
"//ts:jest.config.js",
"@npm//protobufjs",
],
target_compatible_with = select({
"@platforms//os:osx": [],
"@platforms//os:linux": [],
"//conditions:default": ["@platforms//os:linux"],
}),
)

View File

@ -3,14 +3,13 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import NewOptions from "./NewOptions.svelte";
import ReviewOptions from "./ReviewOptions.svelte";
import LapseOptions from "./LapseOptions.svelte";
import GeneralOptions from "./GeneralOptions.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
export let state: DeckConfigState;
</script>
<style lang="scss">
@ -24,8 +23,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</style>
<div>
<NewOptions bind:config {defaults} />
<ReviewOptions bind:config {defaults} />
<LapseOptions bind:config {defaults} />
<GeneralOptions bind:config {defaults} />
<NewOptions {state} />
<ReviewOptions {state} />
<LapseOptions {state} />
<GeneralOptions {state} />
</div>

View File

@ -4,16 +4,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "anki/i18n";
import type { DeckConfigId, ConfigWithCount } from "./lib";
import type { DeckConfigState, ConfigListEntry } from "./lib";
import OptionsDropdown from "./OptionsDropdown.svelte";
export let allConfig: ConfigWithCount[];
export let selectedConfigId: DeckConfigId;
export let state: DeckConfigState;
let configList = state.configList;
function configLabel(config: ConfigWithCount): string {
const name = config.config.name;
const count = tr.deckConfigUsedByDecks({ decks: config.useCount });
return `${name} (${count})`;
function configLabel(entry: ConfigListEntry): string {
const count = tr.deckConfigUsedByDecks({ decks: entry.useCount });
return `${entry.name} (${count})`;
}
function myblur(this: HTMLSelectElement) {
state.setCurrentIndex(parseInt(this.value));
}
</script>
@ -47,12 +50,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="outer">
<div class="inner">
<select bind:value={selectedConfigId} class="form-select">
{#each allConfig as config}
<option value={config.config.id}>{configLabel(config)}</option>
<!-- svelte-ignore a11y-no-onchange -->
<select class="form-select" on:change={myblur}>
{#each $configList as entry}
<option value={entry.idx} selected={entry.current}>
{configLabel(entry)}
</option>
{/each}
</select>
<OptionsDropdown />
<OptionsDropdown {state} />
</div>
</div>

View File

@ -3,24 +3,12 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import ConfigSelector from "./ConfigSelector.svelte";
import ConfigEditor from "./ConfigEditor.svelte";
import * as tr from "anki/i18n";
import type { DeckConfigState } from "./lib";
export let state: DeckConfigState;
let selectedConfigId = state.selectedConfigId;
let selectedConfig: pb.BackendProto.DeckConfig.Config;
$: {
selectedConfig = (
state.allConfigs.find((e) => e.config.id == selectedConfigId)?.config ??
state.allConfigs[0].config
).config as pb.BackendProto.DeckConfig.Config;
}
let defaults = state.defaults;
</script>
<style>
@ -38,12 +26,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
</style>
<div id="modal">
<!-- filled in later-->
</div>
<div class="outer">
<div class="inner">
<div><b>{tr.actionsOptionsFor({ val: state.deckName })}</b></div>
<div><b>{tr.actionsOptionsFor({ val: state.currentDeck.name })}</b></div>
<ConfigSelector allConfig={state.allConfigs} bind:selectedConfigId />
<ConfigSelector {state} />
<ConfigEditor config={selectedConfig} {defaults} />
<ConfigEditor {state} />
</div>
</div>

View File

@ -3,13 +3,14 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import CheckBox from "./CheckBox.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
export let state: DeckConfigState;
let config = state.currentConfig;
let defaults = state.defaults;
</script>
<div>
@ -21,23 +22,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={30}
max={600}
defaultValue={defaults.capAnswerTimeToSecs}
bind:value={config.capAnswerTimeToSecs} />
bind:value={$config.capAnswerTimeToSecs} />
<CheckBox
label="Answer timer"
subLabel={tr.schedulingShowAnswerTimer()}
defaultValue={defaults.showTimer}
bind:value={config.showTimer} />
bind:value={$config.showTimer} />
<CheckBox
label="Autoplay"
subLabel="Don't play audio automatically"
defaultValue={defaults.disableAutoplay}
bind:value={config.disableAutoplay} />
bind:value={$config.disableAutoplay} />
<CheckBox
label="Question Audio"
subLabel={tr.schedulingAlwaysIncludeQuestionSideWhenReplaying()}
defaultValue={defaults.skipQuestionWhenReplayingAnswer}
bind:value={config.skipQuestionWhenReplayingAnswer} />
bind:value={$config.skipQuestionWhenReplayingAnswer} />
</div>

View File

@ -3,15 +3,16 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
export let state: DeckConfigState;
let config = state.currentConfig;
let defaults = state.defaults;
const leechChoices = [tr.actionsSuspendCard(), tr.schedulingTagOnly()];
</script>
@ -23,8 +24,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
label="Relearning steps"
subLabel="Relearning steps, separated by spaces."
defaultValue={defaults.relearnSteps}
value={config.relearnSteps}
on:changed={(evt) => (config.relearnSteps = evt.detail.value)} />
value={$config.relearnSteps}
on:changed={(evt) => ($config.relearnSteps = evt.detail.value)} />
<SpinBoxFloat
label={tr.schedulingNewInterval()}
@ -32,27 +33,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0}
max={1}
defaultValue={defaults.lapseMultiplier}
value={config.lapseMultiplier}
on:changed={(evt) => (config.lapseMultiplier = evt.detail.value)} />
value={$config.lapseMultiplier}
on:changed={(evt) => ($config.lapseMultiplier = evt.detail.value)} />
<SpinBox
label={tr.schedulingMinimumInterval()}
subLabel="The minimum new interval a lapsed card will be given after relearning."
min={1}
defaultValue={defaults.minimumLapseInterval}
bind:value={config.minimumLapseInterval} />
bind:value={$config.minimumLapseInterval} />
<SpinBox
label={tr.schedulingLeechThreshold()}
subLabel="Number of times Again needs to be pressed on a review card to make it a leech."
min={1}
defaultValue={defaults.leechThreshold}
bind:value={config.leechThreshold} />
bind:value={$config.leechThreshold} />
<EnumSelector
label={tr.schedulingLeechAction()}
subLabel=""
choices={leechChoices}
defaultValue={defaults.leechAction}
bind:value={config.leechAction} />
bind:value={$config.leechAction} />
</div>

View File

@ -3,16 +3,17 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte";
import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
export let state: DeckConfigState;
let config = state.currentConfig;
let defaults = state.defaults;
const newOrderChoices = [
tr.schedulingShowNewCardsInOrderAdded(),
@ -21,15 +22,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let stepsExceedGraduatingInterval: boolean;
$: {
const lastLearnStepInDays = config.learnSteps.length
? config.learnSteps[config.learnSteps.length - 1] / 60 / 24
const lastLearnStepInDays = $config.learnSteps.length
? $config.learnSteps[$config.learnSteps.length - 1] / 60 / 24
: 0;
stepsExceedGraduatingInterval =
lastLearnStepInDays > config.graduatingIntervalGood;
lastLearnStepInDays > $config.graduatingIntervalGood;
}
let goodExceedsEasy: boolean;
$: goodExceedsEasy = config.graduatingIntervalGood > config.graduatingIntervalEasy;
$: goodExceedsEasy =
$config.graduatingIntervalGood > $config.graduatingIntervalEasy;
// fixme: change impl; support warning messages
$: newCardsGreaterThanParent = $config.newPerDay > state.currentDeck.parentNewLimit;
</script>
<div>
@ -40,36 +44,37 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
subLabel="Learning steps, separated by spaces."
warn={stepsExceedGraduatingInterval}
defaultValue={defaults.learnSteps}
value={config.learnSteps}
on:changed={(evt) => (config.learnSteps = evt.detail.value)} />
value={$config.learnSteps}
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
<EnumSelector
label={tr.schedulingOrder()}
subLabel=""
choices={newOrderChoices}
defaultValue={defaults.newCardOrder}
bind:value={config.newCardOrder} />
bind:value={$config.newCardOrder} />
<SpinBox
label={tr.schedulingNewCardsday()}
subLabel="The maximum number of new cards to introduce in a day."
min={0}
warn={newCardsGreaterThanParent}
defaultValue={defaults.newPerDay}
bind:value={config.newPerDay} />
bind:value={$config.newPerDay} />
<SpinBox
label={tr.schedulingGraduatingInterval()}
subLabel="Days to wait after answering Good on the last learning step."
warn={stepsExceedGraduatingInterval || goodExceedsEasy}
defaultValue={defaults.graduatingIntervalGood}
bind:value={config.graduatingIntervalGood} />
bind:value={$config.graduatingIntervalGood} />
<SpinBox
label={tr.schedulingEasyInterval()}
subLabel="Days to wait after answering Easy on the first learning step."
warn={goodExceedsEasy}
defaultValue={defaults.graduatingIntervalEasy}
bind:value={config.graduatingIntervalEasy} />
bind:value={$config.graduatingIntervalEasy} />
<SpinBoxFloat
label={tr.schedulingStartingEase()}
@ -77,12 +82,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1.31}
max={5}
defaultValue={defaults.initialEase}
value={config.initialEase}
on:changed={(evt) => (config.initialEase = evt.detail.value)} />
value={$config.initialEase}
on:changed={(evt) => ($config.initialEase = evt.detail.value)} />
<CheckBox
label="Bury New"
subLabel={tr.schedulingBuryRelatedNewCardsUntilThe()}
defaultValue={defaults.buryNew}
bind:value={config.buryNew} />
bind:value={$config.buryNew} />
</div>

View File

@ -2,6 +2,50 @@
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
// import * as tr from "anki/i18n";
import { textInputModal } from "./textInputModal";
import type { DeckConfigState } from "./lib";
export let state: DeckConfigState;
function addConfig(): void {
textInputModal({
title: "Add Config",
prompt: "Name:",
onOk: (text: string) => {
const trimmed = text.trim();
if (trimmed.length) {
state.addConfig(trimmed);
}
},
});
}
function renameConfig(): void {
textInputModal({
title: "Rename Config",
prompt: "Name:",
startingValue: state.getCurrentName(),
onOk: (text: string) => {
state.setCurrentName(text);
},
});
}
function removeConfig(): void {
setTimeout(() => {
if (confirm("Are you sure?")) {
try {
state.removeCurrentConfig();
} catch (err) {
alert(err);
}
}
}, 100);
}
</script>
<style>
:global(svg) {
vertical-align: text-bottom;
@ -18,13 +62,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href={'#'}>Add</a></li>
<li><a class="dropdown-item" href={'#'}>Rename</a></li>
<li><a class="dropdown-item" href={'#'}>Remove</a></li>
<li><a class="dropdown-item" href={'#'} on:click={addConfig}>Add Config</a></li>
<li>
<a class="dropdown-item" href={'#'} on:click={renameConfig}>Rename Config</a>
</li>
<li>
<a class="dropdown-item" href={'#'} on:click={removeConfig}>Remove Config</a>
</li>
<li>
<hr class="dropdown-divider" />
</li>
<input type="checkbox" class="form-check-input" id="dropdownCheck" />
<label class="form-check-label" for="dropdownCheck"> Apply to Children </label>
<li><a class="dropdown-item" href={'#'}>Apply to Child Decks</a></li>
</ul>
</div>

View File

@ -3,14 +3,15 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
export let state: DeckConfigState;
let config = state.currentConfig;
let defaults = state.defaults;
</script>
<div>
@ -21,7 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
subLabel="The maximum number of reviews cards to show in a day."
min={0}
defaultValue={defaults.reviewsPerDay}
bind:value={config.reviewsPerDay} />
bind:value={$config.reviewsPerDay} />
<SpinBoxFloat
label={tr.schedulingEasyBonus()}
@ -29,8 +30,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1}
max={3}
defaultValue={defaults.easyMultiplier}
value={config.easyMultiplier}
on:changed={(evt) => (config.easyMultiplier = evt.detail.value)} />
value={$config.easyMultiplier}
on:changed={(evt) => ($config.easyMultiplier = evt.detail.value)} />
<SpinBoxFloat
label={tr.schedulingIntervalModifier()}
@ -38,8 +39,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.5}
max={2}
defaultValue={defaults.intervalMultiplier}
value={config.intervalMultiplier}
on:changed={(evt) => (config.intervalMultiplier = evt.detail.value)} />
value={$config.intervalMultiplier}
on:changed={(evt) => ($config.intervalMultiplier = evt.detail.value)} />
<SpinBox
label={tr.schedulingMaximumInterval()}
@ -47,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1}
max={365 * 100}
defaultValue={defaults.maximumReviewInterval}
bind:value={config.maximumReviewInterval} />
bind:value={$config.maximumReviewInterval} />
<SpinBoxFloat
label={tr.schedulingHardInterval()}
@ -55,12 +56,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.5}
max={1.3}
defaultValue={defaults.hardMultiplier}
value={config.hardMultiplier}
on:changed={(evt) => (config.hardMultiplier = evt.detail.value)} />
value={$config.hardMultiplier}
on:changed={(evt) => ($config.hardMultiplier = evt.detail.value)} />
<CheckBox
label="Bury Reviews"
subLabel={tr.schedulingBuryRelatedReviewsUntilTheNext()}
defaultValue={defaults.buryReviews}
bind:value={config.buryReviews} />
bind:value={$config.buryReviews} />
</div>

View File

@ -0,0 +1,93 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { onMount, onDestroy } from "svelte";
import Modal from "bootstrap/js/dist/modal";
export let title: string;
export let prompt: string;
export let startingValue = "";
export let onOk: (text: string) => void;
let inputRef: HTMLInputElement;
let modal: Modal;
function onShown(): void {
inputRef.focus();
}
function onHidden(): void {
const container = document.getElementById("modal")!;
container.removeChild(container.firstElementChild!);
}
function onOkClicked(): void {
onOk(inputRef.value);
modal.hide();
}
function onKeyUp(evt: KeyboardEvent): void {
if (evt.code === "Enter") {
onOkClicked();
}
}
onMount(() => {
const container = document.getElementById("modal")!;
container.addEventListener("shown.bs.modal", onShown);
container.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(container.firstElementChild!, {});
modal.show();
});
onDestroy(() => {
const container = document.getElementById("modal")!;
container.removeEventListener("shown.bs.modal", onShown);
container.removeEventListener("hidden.bs.modal", onHidden);
});
</script>
<div class="modal fade" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
<div class="modal-dialog" on:keyup={onKeyUp}>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalLabel">{title}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close" />
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label
for="prompt-input"
class="col-form-label">{prompt}</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class="form-control"
value={startingValue} />
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal">Cancel</button>
<button
type="button"
class="btn btn-primary"
on:click={onOkClicked}>OK</button>
</div>
</div>
</div>
</div>

View File

@ -5,6 +5,8 @@
@import "ts/sass/bootstrap/forms";
@import "ts/sass/bootstrap/buttons";
@import "ts/sass/bootstrap/button-group";
@import "ts/sass/bootstrap/modal";
@import "ts/sass/bootstrap/close";
.night-mode {
@include scrollbar.night-mode;

View File

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getDeckConfigInfo, stateFromUpdateData } from "./lib";
import { getDeckConfigInfo, DeckConfigState } from "./lib";
import { setupI18n, ModuleName } from "anki/i18n";
import { checkNightMode } from "anki/nightmode";
import DeckConfigPage from "./DeckConfigPage.svelte";
@ -15,7 +15,7 @@ export async function deckConfig(
modules: [ModuleName.SCHEDULING, ModuleName.ACTIONS, ModuleName.DECK_CONFIG],
});
const info = await getDeckConfigInfo(deckId);
const state = stateFromUpdateData(info);
const state = new DeckConfigState(info);
new DeckConfigPage({
target,
props: { state },

View File

@ -7,6 +7,9 @@
import pb from "anki/backend_proto";
import { postRequest } from "anki/postrequest";
import { Writable, writable, get, Readable, readable } from "svelte/store";
import { isEqual, cloneDeep } from "lodash-es";
import * as tr from "anki/i18n";
export async function getDeckConfigInfo(
deckId: number
@ -23,30 +26,148 @@ export interface ConfigWithCount {
useCount: number;
}
export interface DeckConfigState {
deckName: string;
selectedConfigId: DeckConfigId;
removedConfigs: DeckConfigId[];
renamedConfigs: Map<DeckConfigId, string>;
allConfigs: ConfigWithCount[];
defaults: pb.BackendProto.DeckConfig.Config;
/// Info for showing the top selector
export interface ConfigListEntry {
idx: number;
name: string;
useCount: number;
current: boolean;
}
export function stateFromUpdateData(
data: pb.BackendProto.DeckConfigForUpdate
): DeckConfigState {
const current = data.currentDeck as pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
return {
deckName: current.name,
selectedConfigId: current.configId,
removedConfigs: [],
renamedConfigs: new Map(),
allConfigs: data.allConfig.map((config) => {
type ConfigInner = pb.BackendProto.DeckConfig.Config;
export class DeckConfigState {
readonly currentConfig: Writable<ConfigInner>;
readonly configList: Readable<ConfigListEntry[]>;
readonly currentDeck: pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
readonly defaults: ConfigInner;
private configs: ConfigWithCount[];
private selectedIdx: number;
private configListSetter?: (val: ConfigListEntry[]) => void;
private removedConfigs: DeckConfigId[] = [];
constructor(data: pb.BackendProto.DeckConfigForUpdate) {
this.currentDeck = data.currentDeck as pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
this.defaults = data.defaults!.config! as ConfigInner;
this.configs = data.allConfig.map((config) => {
return {
config: config.config as pb.BackendProto.DeckConfig,
useCount: config.useCount!,
};
}),
defaults: data.defaults!.config! as pb.BackendProto.DeckConfig.Config,
});
this.selectedIdx =
this.configs.findIndex((c) => c.config.id === this.currentDeck.configId) ??
0;
// decrement the use count of the starting item, as we'll apply +1 to currently
// selected one at display time
this.configs[this.selectedIdx].useCount -= 1;
this.currentConfig = writable(this.getCurrentConfig());
this.configList = readable(this.getConfigList(), (set) => {
this.configListSetter = set;
return undefined;
});
}
setCurrentIndex(index: number): void {
this.saveCurrentConfig();
this.selectedIdx = index;
this.updateCurrentConfig();
// use counts have changed
this.updateConfigList();
}
/// Persist any changes made to the current config into the list of configs.
saveCurrentConfig(): void {
const config = get(this.currentConfig);
if (!isEqual(config, this.configs[this.selectedIdx].config.config)) {
console.log("save");
this.configs[this.selectedIdx].config.config = config;
this.configs[this.selectedIdx].config.mtimeSecs = 0;
} else {
console.log("no changes");
}
}
getCurrentName(): string {
return this.configs[this.selectedIdx].config.name;
}
setCurrentName(name: string): void {
if (this.configs[this.selectedIdx].config.name === name) {
return;
}
const uniqueName = this.ensureNewNameUnique(name);
this.configs[this.selectedIdx].config.name = uniqueName;
this.configs[this.selectedIdx].config.mtimeSecs = 0;
this.updateConfigList();
}
/// Adds a new config, making it current.
/// not already a new config.
addConfig(name: string): void {
const uniqueName = this.ensureNewNameUnique(name);
const config = pb.BackendProto.DeckConfig.create({
id: 0,
name: uniqueName,
config: cloneDeep(this.defaults),
});
const configWithCount = { config, useCount: 0 };
this.configs.push(configWithCount);
this.selectedIdx = this.configs.length - 1;
this.updateCurrentConfig();
this.updateConfigList();
}
/// Will throw if the default deck is selected.
removeCurrentConfig(): void {
const currentId = this.configs[this.selectedIdx].config.id;
if (currentId === 1) {
throw "can't remove default config";
}
if (currentId !== 0) {
this.removedConfigs.push(currentId);
}
this.configs.splice(this.selectedIdx, 1);
this.selectedIdx = Math.max(0, this.selectedIdx - 1);
this.updateCurrentConfig();
this.updateConfigList();
}
private ensureNewNameUnique(name: string): string {
if (this.configs.find((e) => e.config.name === name) !== undefined) {
return name + (new Date().getTime() / 1000).toFixed(0);
} else {
return name;
}
}
private updateCurrentConfig(): void {
this.currentConfig.set(this.getCurrentConfig());
}
private updateConfigList(): void {
this.configListSetter?.(this.getConfigList());
}
/// Returns a copy of the currently selected config.
private getCurrentConfig(): ConfigInner {
return cloneDeep(this.configs[this.selectedIdx].config.config as ConfigInner);
}
private getConfigList(): ConfigListEntry[] {
const list: ConfigListEntry[] = this.configs.map((c, idx) => {
const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0);
return {
name: c.config.name,
current: idx === this.selectedIdx,
idx,
useCount,
};
});
list.sort((a, b) =>
a.name.localeCompare(b.name, tr.i18n.langs, { sensitivity: "base" })
);
return list;
}
}

View File

@ -0,0 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import TextInputModal from "./TextInputModal.svelte";
export interface TextInputModalProps {
title: string;
prompt: string;
startingValue?: string;
onOk: (string) => void;
}
export function textInputModal(props: TextInputModalProps): TextInputModal {
const target = document.getElementById("modal")!;
return new TextInputModal({
target,
props,
});
}

View File

@ -100,7 +100,6 @@ jest_test(
data = [
":test_lib",
"//ts:jest.config.js",
"//ts:package.json",
"@npm//protobufjs",
],
target_compatible_with = select({