diff --git a/next-app/src/lib/forms/Button.svelte b/next-app/src/lib/forms/Button.svelte index dea5340800..787e40c0d5 100644 --- a/next-app/src/lib/forms/Button.svelte +++ b/next-app/src/lib/forms/Button.svelte @@ -1,12 +1,13 @@ - diff --git a/next-app/src/routes/projects/[project_code]/+page.svelte b/next-app/src/routes/projects/[project_code]/+page.svelte index 727578f34a..00eb8d52e4 100644 --- a/next-app/src/routes/projects/[project_code]/+page.svelte +++ b/next-app/src/routes/projects/[project_code]/+page.svelte @@ -17,6 +17,7 @@ export let data: DashboardData let only_showing_subset = true + let loading = false; $: project = data.project $: activities = data.activities @@ -53,8 +54,9 @@ ].filter(({ value }) => value !== undefined) async function load_all_activities() { + loading = true activities = await GET({url: `/projects/${$page.params.project_code}/activities`}) - + loading = false only_showing_subset = false } @@ -80,8 +82,8 @@ {#if only_showing_subset} - diff --git a/next-app/src/routes/projects/[project_code]/Activity.svelte b/next-app/src/routes/projects/[project_code]/Activity.svelte index 94376200dd..37bd663b3e 100644 --- a/next-app/src/routes/projects/[project_code]/Activity.svelte +++ b/next-app/src/routes/projects/[project_code]/Activity.svelte @@ -5,6 +5,7 @@ interface AugmentedActivity extends Activity { date_locale: string, + date_time_locale: string, date_iso: string, time: number, field_names: string, @@ -36,6 +37,7 @@ return { ...activity, date_locale: date.toLocaleDateString(), + date_time_locale: date.toLocaleString(), date_iso: date.toISOString().split('T')[0], time: date.getTime(), field_names: to_names(activity.fields), @@ -44,16 +46,25 @@ } function byDateThenUser(a: AugmentedActivity, b: AugmentedActivity) { - return a.date_iso === b.date_iso ? a.user === b.user ? des(a.time, b.time) - : asc(a.user.username, b.user.username) - : des(a.date_iso, b.date_iso) + if (a.date_iso !== b.date_iso) { + return des(a.date_iso, b.date_iso); + } + + if (a.user.username !== b.user.username) { + return asc(a.user.username, b.user.username); + } + + return des(a.time, b.time); } const asc = (a: string | number, b: string | number) => a > b ? 1 : -1 const des = (a: string | number, b: string | number) => a < b ? 1 : -1 function to_names(fields: Field[] = []): string { - return fields.map(field => field.name).join(', ') + // This is quite rudimentary, but far better than nothing + return [...new Set(fields.map(field => field.fieldLabel?.label) + .filter(label => !!label))] + .join(', ') } @@ -63,20 +74,20 @@ user - date action entry fields + date {#each sorted_activities as activity} { activity.user.username } - { activity.date_locale } { action_display[activity.action] || activity.action } { activity.entry || '—' } { activity.field_names || '—' } + { activity.date_time_locale } {:else} No activity diff --git a/next-app/src/routes/projects/[project_code]/activities/+server.ts b/next-app/src/routes/projects/[project_code]/activities/+server.ts index 717fd51818..e767b5955d 100644 --- a/next-app/src/routes/projects/[project_code]/activities/+server.ts +++ b/next-app/src/routes/projects/[project_code]/activities/+server.ts @@ -13,7 +13,7 @@ type LegacyResult = { } export type Field = { - name: string, + fieldLabel?: { label: string }, } type LegacyActivity = { diff --git a/src/Api/Model/Shared/Dto/ActivityListDto.php b/src/Api/Model/Shared/Dto/ActivityListDto.php index b722c0476f..939e289cc8 100644 --- a/src/Api/Model/Shared/Dto/ActivityListDto.php +++ b/src/Api/Model/Shared/Dto/ActivityListDto.php @@ -104,7 +104,7 @@ public static function getActivityForUser($userId, $filterParams = []) $unreadItems = array_merge($unreadItems, self::getUnreadActivityForUserInProject($userId, $project["id"])); } $unreadItems = array_merge($unreadItems, self::getGlobalUnreadActivityForUser($userId)); - uasort($activity, ["self", "sortActivity"]); + usort($activity, ["self", "sortActivity"]); $dto = [ "activity" => $activity, "unread" => $unreadItems, @@ -124,7 +124,7 @@ public static function getActivityForOneProject($projectModel, $userId, $filterP { $activity = self::getActivityForProject($projectModel, $filterParams); $unreadItems = self::getUnreadActivityForUserInProject($userId, $projectModel->id->asString()); - uasort($activity, ["self", "sortActivity"]); + usort($activity, ["self", "sortActivity"]); $dto = [ "activity" => $activity, "unread" => $unreadItems, @@ -146,7 +146,7 @@ public static function getActivityForOneLexEntry($projectModel, $entryId, $filte // TODO: handle unread items for this activity log type (single-entry). Perhaps the getUnreadActivity() functions should just take a list of items? 2018-02 RM // $unreadItems = self::getUnreadActivityForUserInProject($userId, $projectModel->id->asString()); $unreadItems = []; - uasort($activity, ["self", "sortActivity"]); + usort($activity, ["self", "sortActivity"]); $dto = [ "activity" => $activity, "unread" => $unreadItems, diff --git a/src/angular-app/languageforge/lexicon/editor/_editor.scss b/src/angular-app/languageforge/lexicon/editor/_editor.scss index 82a27f0ba6..7e6669ecc2 100644 --- a/src/angular-app/languageforge/lexicon/editor/_editor.scss +++ b/src/angular-app/languageforge/lexicon/editor/_editor.scss @@ -4,6 +4,35 @@ @import "../../../../Site/views/languageforge/theme/default/sass/variables"; +#lexAppEditView { + display: flex; + justify-content: center; + .container { + margin: 0; + } +} + +.container-sticky { + position: sticky; + top: 0px; + z-index: 30; + margin-bottom: 10px; + background: white; +} + +.container-scroll { + overflow-y: scroll; + overflow-x: hidden; + + @include media-breakpoint-up(md) { + height: calc(100vh - 240px); + } + + @include media-breakpoint-down(sm) { + height: calc(100vh - 215px); + } +} + .lexAppToolbar { border: 1px solid $line-color; margin: 0 0 5px; diff --git a/src/angular-app/languageforge/lexicon/editor/comment/lex-comments-view.scss b/src/angular-app/languageforge/lexicon/editor/comment/lex-comments-view.scss index 6b77bfa0bb..b19ce7f574 100644 --- a/src/angular-app/languageforge/lexicon/editor/comment/lex-comments-view.scss +++ b/src/angular-app/languageforge/lexicon/editor/comment/lex-comments-view.scss @@ -3,34 +3,6 @@ @import "../../../../../../node_modules/bootstrap/scss/variables"; @import "../../../../../../node_modules/bootstrap/scss/mixins"; -#lexAppEditView { - display: flex; - justify-content: center; - .container { - margin: 0; - } -} - -.container-sticky { - position: sticky; - top: 0px; - z-index: 3; - margin-bottom: 10px; -} - -.container-scroll { - overflow-y: scroll; - overflow-x: hidden; - - @include media-breakpoint-up(md) { - height: calc(100vh - 230px); - } - - @include media-breakpoint-down(sm) { - height: calc(100vh - 215px); - } -} - .comments-right-panel { color: $Eden; font-size: 14px; diff --git a/src/angular-app/languageforge/lexicon/editor/editor.component.ts b/src/angular-app/languageforge/lexicon/editor/editor.component.ts index 4d1cea6fb7..e1f6a677b9 100644 --- a/src/angular-app/languageforge/lexicon/editor/editor.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/editor.component.ts @@ -32,7 +32,8 @@ import { import { LexiconProject } from '../shared/model/lexicon-project.model'; import { LexOptionList } from '../shared/model/option-list.model'; import { FieldControl } from './field/field-control.model'; -import {OfflineCacheUtilsService} from '../../../bellows/core/offline/offline-cache-utils.service'; +import { OfflineCacheUtilsService } from '../../../bellows/core/offline/offline-cache-utils.service'; +import { IDeferred } from 'angular'; class Show { more: () => void; @@ -77,6 +78,7 @@ export class LexiconEditorController implements angular.IController { private pristineEntry: LexEntry = new LexEntry(); private warnOfUnsavedEditsId: string; + private saving$: IDeferred; static $inject = ['$filter', '$interval', '$q', '$scope', @@ -156,7 +158,7 @@ export class LexiconEditorController implements angular.IController { this.saveCurrentEntry(); } // destroy listeners when leaving editor page - angular.element(window).unbind('keyup', (e: Event) => {}); + angular.element(window).unbind('keyup', (e: Event) => { }); }; this.show.entryListModifiers = !(this.$window.localStorage.getItem('viewFilter') == null || @@ -223,7 +225,7 @@ export class LexiconEditorController implements angular.IController { $onDestroy(): void { this.cancelAutoSaveTimer(); this.saveCurrentEntry(); - angular.element(window).unbind('keydown', (e: Event) => {}); + angular.element(window).unbind('keydown', (e: Event) => { }); } navigateToLiftImport(): void { @@ -353,11 +355,18 @@ export class LexiconEditorController implements angular.IController { return diffs && diffs.length && diffs.some((diff) => diff.kind === 'A'); } - saveCurrentEntry = (doSetEntry: boolean = false, successCallback: () => void = () => { }, + saveCurrentEntry = async (doSetEntry: boolean = false, successCallback: () => void = () => { }, failCallback: (reason?: any) => void = () => { }) => { + const isNewEntry = LexiconEditorController.entryIsNew(this.currentEntry); + if (isNewEntry) { + // We have to wait for the initial save to complete so that we save with the same entry ID + await this.saving$?.promise; + } + + this.saving$ = this.$q.defer(); + // `doSetEntry` is mainly used for when the save button is pressed, that is when the user is saving the current // entry and is NOT going to a different entry (as is the case with editing another entry. - let isNewEntry = false; let newEntryTempId: string; if (this.hasUnsavedChanges() && this.lecRights.canEditEntry()) { @@ -367,8 +376,7 @@ export class LexiconEditorController implements angular.IController { this.currentEntry = LexiconEditorController.normalizeStrings(this.currentEntry); this.control.currentEntry = this.currentEntry; const entryToSave = angular.copy(this.currentEntry); - if (LexiconEditorController.entryIsNew(entryToSave)) { - isNewEntry = true; + if (isNewEntry) { newEntryTempId = entryToSave.id; entryToSave.id = ''; // send empty id to indicate "create new" } @@ -379,18 +387,20 @@ export class LexiconEditorController implements angular.IController { id: entryForUpdate.id, _update_deep_diff: diff(LexiconEditorController.normalizeStrings(pristineEntryForDiffing), entryForDiffing) }; + let entryOrDiff = isNewEntry ? entryForUpdate : diffForUpdate; if (!isNewEntry && this.hasArrayChange(diffForUpdate._update_deep_diff)) { // Updates involving adding or deleting any array item cannot be delta updates due to MongoDB limitations entryOrDiff = entryForUpdate; } - return this.$q.all({ - entry: this.lexService.update(entryOrDiff), - isSR: this.sendReceive.isSendReceiveProject() - }).then(data => { - const entry = data.entry.data; - if (!entry && data.isSR) { + try { + const { result: { data: entry }, isSR } = await this.$q.all({ + result: this.lexService.update(entryOrDiff), + isSR: this.sendReceive.isSendReceiveProject() + }); + + if (!entry && isSR) { this.warnOfUnsavedEdits(entryToSave); this.sendReceive.startSyncStatusTimer(); } @@ -429,41 +439,41 @@ export class LexiconEditorController implements angular.IController { } // refresh data will add the new entry to the entries list - this.editorService.refreshEditorData().then(() => { - this.activityService.markRefreshRequired(); - if (entry && isNewEntry) { - this.setCurrentEntry(this.entries[this.editorService.getIndexInList(entry.id, this.entries)]); - this.editorService.removeEntryFromLists(newEntryTempId); - - if (doSetEntry) { - this.$state.go('.', { - entryId: entry.id, - }, { notify: false }); - - this.scrollListToEntry(entry.id, 'top'); - } + await this.editorService.refreshEditorData(); + this.activityService.markRefreshRequired(); + if (entry && isNewEntry) { + this.setCurrentEntry(this.entries[this.editorService.getIndexInList(entry.id, this.entries)]); + this.editorService.removeEntryFromLists(newEntryTempId); + + if (doSetEntry) { + this.$state.go('.', { + entryId: entry.id, + }, { notify: false }); + + this.scrollListToEntry(entry.id, 'top'); } - }); - this.saveStatus = 'saved'; - successCallback(); - }).catch(reason => { + this.saveStatus = 'saved'; + successCallback(); + } + } catch (reason) { this.saveStatus = 'unsaved'; failCallback(reason); - }); + } } else { successCallback(); } + this.saving$.resolve(); } - editEntryAndScroll(id: string): void { - this.editEntry(id); + async editEntryAndScroll(id: string): Promise { + await this.editEntry(id); this.scrollListToEntry(id); } - editEntry(id: string): void { + async editEntry(id: string): Promise { if (this.currentEntry.id !== id) { - this.saveCurrentEntry(); + await this.saveCurrentEntry(); this.setCurrentEntry(this.entries[this.editorService.getIndexInList(id, this.entries)]); // noinspection JSIgnoredPromiseFromCall - comments will load in the background this.commentService.loadEntryComments(id); @@ -476,10 +486,10 @@ export class LexiconEditorController implements angular.IController { this.goToEntry(id); } - gotoToEntry(index: number, isValid: boolean) { + async gotoToEntry(index: number, isValid: boolean): Promise { if (isValid) { let id = this.editorService.getIdInFilteredList(Number(index)); - this.editEntryAndScroll(id); + await this.editEntryAndScroll(id); } } @@ -495,9 +505,9 @@ export class LexiconEditorController implements angular.IController { return i >= 0 && i < this.visibleEntries.length; } - skipToEntry(distance: number): void { + async skipToEntry(distance: number): Promise { const i = this.editorService.getIndexInList(this.currentEntry.id, this.visibleEntries) + distance; - this.editEntry(this.visibleEntries[i].id); + await this.editEntry(this.visibleEntries[i].id); this.scrollListToEntry(this.visibleEntries[i].id); } @@ -518,30 +528,32 @@ export class LexiconEditorController implements angular.IController { }); } - deleteEntry = (entry: LexEntry): void => { + deleteEntry = async (entry: LexEntry): Promise => { const deleteMsg = 'Are you sure you want to delete the entry \'' + LexiconUtilityService.getLexeme(this.lecConfig, this.lecConfig.entry, entry) + '\'?'; - this.modal.showModalSimple('Delete Entry', deleteMsg, 'Cancel', 'Delete Entry').then(() => { - let iShowList = this.editorService.getIndexInList(entry.id, this.visibleEntries); - this.editorService.removeEntryFromLists(entry.id); - if (this.entries.length > 0) { - if (iShowList !== 0) { - iShowList--; - } - this.editEntryAndScroll(this.visibleEntries[iShowList].id); - } else { - this.returnToList(); - } + await this.modal.showModalSimple('Delete Entry', deleteMsg, 'Cancel', 'Delete Entry'); - if (!LexiconEditorController.entryIsNew(entry)) { - this.sendReceive.setStateUnsynced(); - this.lexService.remove(entry.id, () => { - this.editorService.refreshEditorData(); - }); + let iShowList = this.editorService.getIndexInList(entry.id, this.visibleEntries); + this.editorService.removeEntryFromLists(entry.id); + if (this.entries.length > 0) { + if (iShowList !== 0) { + iShowList--; } + this.currentEntry = new LexEntry(); + this.pristineEntry = new LexEntry(); + await this.editEntryAndScroll(this.visibleEntries[iShowList].id); + } else { + this.returnToList(); + } - this.hideRightPanel(); - }, () => { }); + if (!LexiconEditorController.entryIsNew(entry)) { + this.sendReceive.setStateUnsynced(); + this.lexService.remove(entry.id, () => { + this.editorService.refreshEditorData(); + }); + } + + this.hideRightPanel(); } makeValidModelRecursive = (config: LexConfigField, data: any = {}, stopAtNodes: string | string[] = []): any => { @@ -969,7 +981,7 @@ export class LexiconEditorController implements angular.IController { } } - private evaluateStateFromURL(): void { + private async evaluateStateFromURL(): Promise { this.editorService.loadEditorData().then(async () => { if (this.$state.is("editor.entry")) { @@ -982,7 +994,7 @@ export class LexiconEditorController implements angular.IController { // see if there is a most-recently viewed entry in the cache await this.offlineCacheUtils.getProjectMruEntryData().then(data => { - if(data && data.mruEntryId && this.editorService.getIndexInList(data.mruEntryId, this.entries) != null){ + if (data && data.mruEntryId && this.editorService.getIndexInList(data.mruEntryId, this.entries) != null) { entryId = data.mruEntryId; } @@ -992,7 +1004,7 @@ export class LexiconEditorController implements angular.IController { } }); } - this.editEntryAndScroll(entryId); + await this.editEntryAndScroll(entryId); } else { // there are no entries, go to the list view this.$state.go('editor.list'); @@ -1285,7 +1297,7 @@ export class LexiconEditorController implements angular.IController { } private static syncListEntryWithCurrentEntry(elementId: string, alignment: string = 'center'): void { - const element = document.querySelector(elementId); + const element = document.querySelector(elementId); const block = alignment !== 'top' ? 'center' : 'start'; // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView diff --git a/src/angular-app/languageforge/lexicon/editor/field/field-control.model.ts b/src/angular-app/languageforge/lexicon/editor/field/field-control.model.ts index acba579671..5ec8e76437 100644 --- a/src/angular-app/languageforge/lexicon/editor/field/field-control.model.ts +++ b/src/angular-app/languageforge/lexicon/editor/field/field-control.model.ts @@ -14,7 +14,7 @@ export class FieldControl { commentContext: { contextGuid: string }; config: LexiconConfig; currentEntry: LexEntry; - deleteEntry: (currentEntry: LexEntry) => void; + deleteEntry: (currentEntry: LexEntry) => Promise; getContextParts: (contextGuid: string) => any; getNewComment?: () => LexComment; hideRightPanel: () => void;