Skip to content

Commit ac1ac8a

Browse files
hahn-kevmyieye
andauthored
Create tasks view (#1932)
* implement tasks view a task list a list of entries pending the task a subject popup to enter the value for the task a done page to see what you've done so far a review page to review all the modified entries * enable filtering on rich text with gridify * ensure task text is updated when local changes --------- Co-authored-by: Tim Haasdyk <tim_haasdyk@sil.org>
1 parent df943d2 commit ac1ac8a

38 files changed

Lines changed: 1706 additions & 105 deletions

backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,42 @@ async Task<Entry> IMiniLcmWriteApi.UpdateEntry(Entry before, Entry after, IMiniL
9696
return result;
9797
}
9898

99+
async Task<Sense> IMiniLcmWriteApi.CreateSense(Guid entryId, Sense sense, BetweenPosition? position)
100+
{
101+
await using var _ = BeginTrackingChanges();
102+
var result = await _api.CreateSense(entryId, sense, position);
103+
var entry = await _api.GetEntry(entryId) ?? throw new NullReferenceException($"Entry {entryId} not found");
104+
NotifyEntryChanged(entry);
105+
return result;
106+
}
107+
108+
async Task<Sense> IMiniLcmWriteApi.UpdateSense(Guid entryId, Sense before, Sense after, IMiniLcmApi? api)
109+
{
110+
await using var _ = BeginTrackingChanges();
111+
var result = await _api.UpdateSense(entryId, before, after, api ?? this);
112+
var entry = await _api.GetEntry(entryId) ?? throw new NullReferenceException($"Entry {entryId} not found");
113+
NotifyEntryChanged(entry);
114+
return result;
115+
}
116+
117+
async Task<ExampleSentence> IMiniLcmWriteApi.CreateExampleSentence(Guid entryId, Guid senseId, ExampleSentence exampleSentence, BetweenPosition? position)
118+
{
119+
await using var _ = BeginTrackingChanges();
120+
var result = await _api.CreateExampleSentence(entryId, senseId, exampleSentence, position);
121+
var entry = await _api.GetEntry(entryId) ?? throw new NullReferenceException($"Entry {entryId} not found");
122+
NotifyEntryChanged(entry);
123+
return result;
124+
}
125+
126+
async Task<ExampleSentence> IMiniLcmWriteApi.UpdateExampleSentence(Guid entryId, Guid senseId, ExampleSentence before, ExampleSentence after, IMiniLcmApi? api)
127+
{
128+
await using var _ = BeginTrackingChanges();
129+
var result = await _api.UpdateExampleSentence(entryId, senseId, before, after, api ?? this);
130+
var entry = await _api.GetEntry(entryId) ?? throw new NullReferenceException($"Entry {entryId} not found");
131+
NotifyEntryChanged(entry);
132+
return result;
133+
}
134+
99135
async Task<ComplexFormComponent> IMiniLcmWriteApi.CreateComplexFormComponent(ComplexFormComponent complexFormComponent, BetweenPosition<ComplexFormComponent>? position)
100136
{
101137
var result = await _api.CreateComplexFormComponent(complexFormComponent, position);

backend/FwLite/LcmCrdt/EntryFilterMapProvider.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@ public class EntryFilterMapProvider : EntryFilterMapProvider<Entry>
1515
EntryFilter.NormalizeEmptyToNull<SemanticDomain>;
1616
public override Expression<Func<Entry, object?>> EntrySensesExampleSentences => e => e.Senses.Select(s => s.ExampleSentences);
1717
public override Expression<Func<Entry, string, object?>> EntrySensesExampleSentencesSentence =>
18-
(e, ws) => e.Senses.SelectMany(s => s.ExampleSentences).Select(example => Json.Value(example.Sentence, ms => ms[ws]));
18+
(e, ws) => e.Senses.SelectMany(s => s.ExampleSentences).Select(example => Json.Value(example.Sentence, ms => ms[ws])!.GetPlainText());
1919
public override Expression<Func<Entry, object?>> EntrySensesPartOfSpeechId => e => e.Senses.Select(s => s.PartOfSpeechId);
2020
public override Expression<Func<Entry, object?>> EntrySenses => e => e.Senses;
2121

2222
public override Expression<Func<Entry, string, object?>> EntrySensesGloss =>
2323
(entry, ws) => entry.Senses.Select(s => Json.Value(s.Gloss, ms => ms[ws]));
2424
public override Expression<Func<Entry, string, object?>> EntrySensesDefinition =>
25-
(entry, ws) => entry.Senses.Select(s => Json.Value(s.Definition, ms => ms[ws]));
26-
public override Expression<Func<Entry, string, object?>> EntryNote => (entry, ws) => Json.Value(entry.Note, ms => ms[ws]);
25+
(entry, ws) => entry.Senses.Select(s => Json.Value(s.Definition, ms => ms[ws])!.GetPlainText());
26+
public override Expression<Func<Entry, string, object?>> EntryNote => (entry, ws) => Json.Value(entry.Note, ms => ms[ws])!.GetPlainText();
2727
public override Expression<Func<Entry, string, object?>> EntryLexemeForm => (entry, ws) => Json.Value(entry.LexemeForm, ms => ms[ws]);
2828
public override Expression<Func<Entry, string, object?>> EntryCitationForm => (entry, ws) => Json.Value(entry.CitationForm, ms => ms[ws]);
29-
public override Expression<Func<Entry, string, object?>> EntryLiteralMeaning => (entry, ws) => Json.Value(entry.LiteralMeaning, ms => ms[ws]);
29+
public override Expression<Func<Entry, string, object?>> EntryLiteralMeaning => (entry, ws) => Json.Value(entry.LiteralMeaning, ms => ms[ws])!.GetPlainText();
3030
public override Expression<Func<Entry, object?>> EntryComplexFormTypes => e => e.ComplexFormTypes;
3131
public override Func<string, object>? EntryComplexFormTypesConverter => EntryFilter.NormalizeEmptyToEmptyList<ComplexFormType>;
3232
}

backend/FwLite/LcmCrdt/Json.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public void Build(Sql.ISqExtensionBuilder builder)
4848

4949
var returnType = ((MethodInfo)builder.Member).ReturnType;
5050

51-
if (returnType != typeof(string))
51+
if (returnType != typeof(string) && returnType != typeof(RichString))//bypass rich string so it can be used with .GetPlainText()
5252
{
5353
valueExpression = PseudoFunctions.MakeTryConvert(new SqlDataType(new DbDataType(returnType)),
5454
new SqlDataType(new DbDataType(typeof(string), DataType.Text)),
@@ -80,7 +80,7 @@ private static void BuildParameterPath(Expression? pathBody,
8080
}
8181
else
8282
{
83-
throw new InvalidOperationException("Invalid property path.");
83+
throw new InvalidOperationException($"Invalid property path for expression {mce}.");
8484
}
8585

8686
break;
@@ -182,6 +182,12 @@ private static IQueryable<JsonEach<string>> QueryInternal(this MultiString value
182182
throw new NotImplementedException("only supported server side");
183183
}
184184

185+
[Sql.Expression("(select group_concat(s.value->>'Text', '') from json_each({0}->>'Spans') as s)", PreferServerSide = true)]
186+
public static string GetPlainText(RichString? richString)
187+
{
188+
return richString?.GetPlainText() ?? "";
189+
}
190+
185191
//maps to a row from json_each
186192
private record JsonEach<T>(
187193
[property: Column("value")] T Value,

backend/FwLite/LcmCrdt/LcmCrdtKernel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ private static void ConfigureDbOptions(IServiceProvider provider, DbContextOptio
112112
nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter)))
113113
//tells linq2db to rewrite Sense.SemanticDomains, into Json.Query(Sense.SemanticDomains)
114114
.Entity<Sense>().Property(s => s.SemanticDomains).HasAttribute(new ExpressionMethodAttribute(SenseSemanticDomainsExpression()))
115+
.Entity<RichString>().Member(r => r.GetPlainText()).IsExpression(r => Json.GetPlainText(r))
115116
.Build();
116117
mappingSchema.SetConvertExpression((WritingSystemId id) =>
117118
new DataParameter { Value = id.Code, DataType = DataType.Text });

backend/FwLite/MiniLcm.Tests/QueryEntryTestsBase.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,23 @@ public async Task CanFilterLexemeContainsAAndNoComplexFormTypes()
216216
results.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(Apple, Banana);
217217
}
218218

219-
[Fact(Skip = "Does not work due to Sentence being a rich string now")]
219+
[Fact]
220220
public async Task CanFilterExampleSentenceText()
221221
{
222222
var results = await Api.GetEntries(new(Filter: new() { GridifyFilter = "Senses.ExampleSentences.Sentence[en]=*phone" })).ToArrayAsync();
223223
results.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(Banana);
224224
}
225225

226+
[Fact]
227+
public async Task CanFilterToExampleSentenceWithMissingSentence()
228+
{
229+
var results = await Api
230+
.GetEntries(new(Filter: new() { GridifyFilter = "Senses.ExampleSentences.Sentence[es]=" })).ToArrayAsync();
231+
//Senses.ExampleSentences=null matches entries which have senses but no examples
232+
//it does not include Apple because it has no senses, to include it a filter Senses=null is needed
233+
results.Select(e => e.LexemeForm["en"]).Should().BeEquivalentTo(Kiwi, Banana);
234+
}
235+
226236
[Theory]
227237
[InlineData("a", "a")]
228238
[InlineData("a", "A")]

frontend/viewer/src/lib/DictionaryEntry.svelte

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,18 @@
1212
lines = $bindable(),
1313
actions,
1414
class: className,
15+
headwordClass = '',
16+
highlightSenseId = undefined,
17+
hideExamples = false,
1518
...restProps
1619
}: HTMLAttributes<HTMLDivElement> & {
1720
entry: IEntry,
1821
showLinks?: boolean,
1922
lines?: number,
20-
actions?: Snippet
23+
actions?: Snippet,
24+
headwordClass?: string,
25+
highlightSenseId?: string,
26+
hideExamples?: boolean,
2127
} = $props();
2228
2329
$effect(() => {
@@ -101,7 +107,7 @@
101107
<div class="float-right group-[&:not(:hover)]/container:invisible relative -top-1">
102108
{@render actions?.()}
103109
</div>
104-
<strong class="mr-1">
110+
<strong class={cn('mr-1', headwordClass)}>
105111
{#each headwords as headword, i (headword.wsId)}
106112
<!-- eslint-disable-next-line svelte/no-useless-mustaches This mustache is not useless, it preserves whitespace -->
107113
{#if i > 0}{' / '}{/if}
@@ -110,32 +116,38 @@
110116
</strong>
111117
{#each senses as sense, i (sense.id)}
112118
{#if senses.length > 1}
113-
<br />
114-
{@render senseNumber(i)}
119+
<br/>
115120
{/if}
116-
{#if sense.partOfSpeech}
117-
<i>{sense.partOfSpeech}</i>
118-
{/if}
119-
<span>
120-
{#each sense.glossesAndDefs as glossAndDef (glossAndDef.wsId)}
121-
<sub class="-mr-0.5">{glossAndDef.wsAbbr}</sub>
122-
{#if glossAndDef.gloss}
123-
<span class={glossAndDef.color}>{glossAndDef.gloss}</span>{#if glossAndDef.definition};{/if}
124-
{/if}
125-
{#if glossAndDef.definition}
126-
<span class={glossAndDef.color}>{glossAndDef.definition}</span>
127-
{/if}
128-
<!-- eslint-disable-next-line svelte/no-useless-mustaches This mustache is not useless, it is deliberately an empty string with no whitespace -->
129-
{''}
121+
<span class={cn(highlightSenseId === sense.id && 'rounded bg-secondary')}>
122+
{#if senses.length > 1}
123+
{@render senseNumber(i)}
124+
{/if}
125+
{#if sense.partOfSpeech}
126+
<i>{sense.partOfSpeech}</i>
127+
{/if}
128+
<span>
129+
{#each sense.glossesAndDefs as glossAndDef (glossAndDef.wsId)}
130+
<sub class="-mr-0.5">{glossAndDef.wsAbbr}</sub>
131+
{#if glossAndDef.gloss}
132+
<span class={glossAndDef.color}>{glossAndDef.gloss}</span>{#if glossAndDef.definition};{/if}
133+
{/if}
134+
{#if glossAndDef.definition}
135+
<span class={glossAndDef.color}>{glossAndDef.definition}</span>
136+
{/if}
137+
<!-- eslint-disable-next-line svelte/no-useless-mustaches This mustache is not useless, it is deliberately an empty string with no whitespace -->
138+
{''}
139+
{/each}
140+
</span>
141+
{#if !hideExamples}
142+
{#each sense.exampleSentences as example (example.id)}
143+
{#each example.sentences as sentence, j (sentence)}
144+
{@const first = j === 0}
145+
{@const last = j === example.sentences.length - 1}
146+
{#if j > 0};{/if}
147+
{#if first}[{/if}<span class={sentence.color}>{sentence.text}</span>{#if last}]{/if}
148+
{/each}
130149
{/each}
150+
{/if}
131151
</span>
132-
{#each sense.exampleSentences as example (example.id)}
133-
{#each example.sentences as sentence, j (sentence)}
134-
{@const first = j === 0}
135-
{@const last = j === example.sentences.length - 1}
136-
{#if j > 0};{/if}
137-
{#if first}[{/if}<span class={sentence.color}>{sentence.text}</span>{#if last}]{/if}
138-
{/each}
139-
{/each}
140152
{/each}
141153
</div>
Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,43 @@
11
<script lang="ts">
2-
32
import {initView, useCurrentView} from '$lib/views/view-service';
43
import type {FieldId} from '$lib/entry-editor/field-data';
5-
import type {FieldView} from './views/view-data';
4+
import type {FieldView, Overrides} from './views/view-data';
5+
import type {Snippet} from 'svelte';
6+
import {watch} from 'runed';
7+
8+
interface Props {
9+
shownFields?: FieldId[];
10+
respectOrder?: boolean;
11+
overrides?: Overrides
12+
children?: Snippet;
13+
}
614
7-
export let shownFields: FieldId[] = [];
8-
export let respectOrder: boolean = false;
15+
let {
16+
shownFields = [],
17+
respectOrder = false,
18+
children,
19+
overrides = {}
20+
}: Props = $props();
921
1022
const currentView = useCurrentView();
11-
const overrideView = initView();
12-
$: {
23+
const overrideView = initView(undefined, false);
24+
watch(() => [shownFields, respectOrder, $currentView, overrides] as const, ([shownFields, respectOrder, currentView, overrides]) => {
1325
$overrideView = {
14-
...$currentView,
15-
fields: Object.fromEntries((Object.entries($currentView.fields) as Array<[FieldId, FieldView]>).map(([id, field]) => {
26+
...currentView,
27+
fields: Object.fromEntries((Object.entries(currentView.fields) as Array<[FieldId, FieldView]>).map(([id, field]) => {
1628
return [id, {
1729
...field,
1830
show: shownFields.includes(id),
1931
order: respectOrder ? shownFields.indexOf(id) : field.order
2032
}];
2133
})
22-
) as Record<FieldId, FieldView>
34+
) as Record<FieldId, FieldView>,
35+
overrides: {
36+
...currentView.overrides,
37+
...overrides
38+
}
2339
};
24-
}
40+
});
2541
</script>
2642

27-
<slot/>
43+
{@render children?.()}

frontend/viewer/src/lib/components/lcm-rich-text-editor/lcm-rich-text-editor.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,14 @@
195195
// mimic <input> 'next' behaviour
196196
const nextTabbable = findNextTabbable(editor.dom);
197197
nextTabbable?.focus();
198+
} else {
199+
//mimic submit
200+
if (editor?.dom) {
201+
const form = editor.dom.closest('form');
202+
if (form) {
203+
form.requestSubmit();
204+
}
205+
}
198206
}
199207
}
200208

frontend/viewer/src/lib/components/ui/button/button.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
loading?: boolean;
4848
icon?: IconClass;
4949
iconProps?: Partial<IconProps>;
50+
autofocus?: boolean;
5051
};
5152
</script>
5253

@@ -66,13 +67,19 @@
6667
loading = false,
6768
icon = undefined,
6869
iconProps: nullableIconProps = undefined,
70+
autofocus = false,
6971
children,
7072
...restProps
7173
}: ButtonProps = $props();
7274
7375
const iconProps = $derived(nullableIconProps && ('icon' in nullableIconProps || 'src' in nullableIconProps)
7476
? nullableIconProps as IconProps
7577
: icon ? {icon, ...nullableIconProps} : undefined);
78+
$effect(() => {
79+
if (autofocus && ref) {
80+
ref.focus();
81+
}
82+
});
7683
</script>
7784

7885
{#snippet content()}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script lang="ts">
2+
import {resource} from 'runed';
3+
import {useMiniLcmApi} from '$lib/services/service-provider';
4+
import EntryEditor from '$lib/entry-editor/object-editors/EntryEditor.svelte';
5+
import {EntryPersistence} from '$lib/entry-editor/entry-persistence.svelte';
6+
import * as Dialog from '$lib/components/ui/dialog';
7+
import {Button} from '$lib/components/ui/button';
8+
import {useCurrentView} from '$lib/views/view-service';
9+
import {pt} from '$lib/views/view-text';
10+
import {t} from 'svelte-i18n-lingui';
11+
import {AppNotification} from '$lib/notifications/notifications';
12+
13+
const api = useMiniLcmApi();
14+
const currentView = useCurrentView();
15+
let entryLabel = $derived(pt($t`Entry`, $t`Word`, $currentView));
16+
let {
17+
entryId,
18+
open = $bindable(false),
19+
}: {
20+
entryId?: string,
21+
open: boolean,
22+
} = $props();
23+
let entryResource = resource(() => entryId, async (entryId) => {
24+
if (!entryId) return undefined;
25+
return await api.getEntry(entryId);
26+
});
27+
$effect(() => {
28+
if (entryResource.error) {
29+
AppNotification.error('Failed to load entry', entryResource.error.message);
30+
}
31+
});
32+
let entry = $derived(entryResource.current);
33+
const entryPersistence = new EntryPersistence(() => entry);
34+
let updating = $state(false);
35+
36+
async function updateEntry() {
37+
if (!entry) return;
38+
updating = true;
39+
await entryPersistence.updateEntry(entry).finally(() => updating = false);
40+
open = false;
41+
}
42+
</script>
43+
<Dialog.Root bind:open>
44+
<Dialog.DialogContent>
45+
<Dialog.DialogHeader>
46+
<Dialog.DialogTitle>{$t`Update ${entryLabel}`}</Dialog.DialogTitle>
47+
</Dialog.DialogHeader>
48+
{#if entryResource.loading}
49+
Loading...
50+
{:else if entry}
51+
<EntryEditor modalMode {entry} canAddSense={false} canAddExample={false}/>
52+
{/if}
53+
<Dialog.DialogFooter>
54+
<Button onclick={() => open = false} variant="secondary">{$t`Cancel`}</Button>
55+
<Button onclick={() => updateEntry()} disabled={updating || !entry || entryResource.loading} loading={updating}>
56+
{$t`Update ${entryLabel}`}
57+
</Button>
58+
</Dialog.DialogFooter>
59+
</Dialog.DialogContent>
60+
</Dialog.Root>

0 commit comments

Comments
 (0)