Skip to content

Commit 1bd33bf

Browse files
committed
Show a progress dialog when exporting user data
This does the following: - Ensure the user has visual feedback that an export is in progress (and no surprise file dialog will appear when it finishes) - Allow the user to cancel an export if it hasn't finished - Makes exports load multiple groups at once to reduce requests, thus improving performance significantly Closes #4345
1 parent cf69352 commit 1bd33bf

File tree

7 files changed

+174
-62
lines changed

7 files changed

+174
-62
lines changed

src/common/misc/TranslationKey.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2037,3 +2037,4 @@ export type TranslationKeyType =
20372037
| "unread_label"
20382038
| "replied_label"
20392039
| "forwarded_label"
2040+
| "userExportProgress_msg"
Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { LoginController } from "../api/main/LoginController.js"
2-
import { CustomerTypeRef, GroupInfoTypeRef, GroupTypeRef } from "../api/entities/sys/TypeRefs.js"
3-
import { assertNotNull, mapNullable, neverNull, pad, promiseMap, renderCsv, stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
2+
import { GroupInfoTypeRef, GroupTypeRef } from "../api/entities/sys/TypeRefs.js"
3+
import { assertNotNull, mapNullable, pad, promiseMap, renderCsv, splitInChunks, stringToUtf8Uint8Array } from "@tutao/tutanota-utils"
44
import { EntityClient } from "../api/common/EntityClient.js"
55
import { FileController } from "../file/FileController.js"
66
import { createDataFile } from "../api/common/DataFile.js"
77
import { CounterFacade } from "../api/worker/facades/lazy/CounterFacade.js"
88
import { CounterType } from "../api/common/TutanotaConstants.js"
9+
import { CancelledError } from "../api/common/error/CancelledError"
10+
11+
const GROUP_DOWNLOAD_SIZE = 50
912

1013
export const CSV_MIMETYPE = "text/csv"
1114
export const USER_CSV_FILENAME = "users.csv"
@@ -19,8 +22,7 @@ interface UserExportData {
1922
aliases: Array<string>
2023
}
2124

22-
export async function exportUserCsv(entityClient: EntityClient, logins: LoginController, fileController: FileController, counterFacade: CounterFacade) {
23-
const data = await loadUserExportData(entityClient, logins, counterFacade)
25+
export async function exportUserCsv(data: readonly UserExportData[], fileController: FileController) {
2426
const csv = renderCsv(
2527
["name", "mail address", "date created", "date deleted", "storage used (in bytes)", "aliases"],
2628
data.map((user) => [
@@ -43,25 +45,58 @@ function formatDate(date: Date): string {
4345
/**
4446
* Load data for each user administrated by the logged in user, in order to be exported
4547
*/
46-
export async function loadUserExportData(entityClient: EntityClient, logins: LoginController, counterFacade: CounterFacade): Promise<Array<UserExportData>> {
47-
const { user } = logins.getUserController()
48-
const { userGroups } = await entityClient.load(CustomerTypeRef, assertNotNull(user.customer))
49-
50-
const groupsAdministeredByUser = await entityClient.loadAll(GroupInfoTypeRef, userGroups)
51-
52-
const usedCustomerStorageCounterValues = await counterFacade.readAllCustomerCounterValues(CounterType.UserStorageLegacy, neverNull(user.customer))
53-
return promiseMap(groupsAdministeredByUser, async (info) => {
54-
const group = await entityClient.load(GroupTypeRef, info.group)
55-
const userStorageCounterValue = usedCustomerStorageCounterValues.find((counterValue) => counterValue.counterId === group.storageCounter)
56-
const usedStorage = userStorageCounterValue != null ? Number(userStorageCounterValue.value) : 0
57-
58-
return {
59-
name: info.name,
60-
mailAddress: info.mailAddress ?? "",
61-
created: info.created,
62-
deleted: info.deleted,
63-
usedStorage,
64-
aliases: info.mailAddressAliases.map((alias) => alias.mailAddress),
48+
export async function loadUserExportData(
49+
entityClient: EntityClient,
50+
logins: LoginController,
51+
counterFacade: CounterFacade,
52+
onProgress?: (complete: number, total: number) => unknown,
53+
abortSignal?: AbortSignal,
54+
): Promise<UserExportData[]> {
55+
const customer = await logins.getUserController().loadCustomer()
56+
const groupsAdministeredByUser = await entityClient.loadAll(GroupInfoTypeRef, customer.userGroups)
57+
const usedCustomerStorageCounterValues = await counterFacade.readAllCustomerCounterValues(CounterType.UserStorageLegacy, customer._id)
58+
59+
let isCancelled = false
60+
abortSignal?.addEventListener("abort", () => (isCancelled = true))
61+
62+
let total = groupsAdministeredByUser.length
63+
let completed = 0
64+
onProgress?.(completed, total)
65+
66+
const downloaded = await promiseMap(splitInChunks(GROUP_DOWNLOAD_SIZE, groupsAdministeredByUser), async (infos) => {
67+
if (isCancelled) {
68+
throw new CancelledError("user export cancelled by user")
6569
}
70+
71+
const groups = await entityClient.loadMultiple(
72+
GroupTypeRef,
73+
null,
74+
infos.map((group) => group.group),
75+
)
76+
77+
const mapped = groups.map((group) => {
78+
const info = assertNotNull(groupsAdministeredByUser.find((groupInfo) => groupInfo.group === group._id))
79+
const userStorageCounterValue = usedCustomerStorageCounterValues.find((counterValue) => counterValue.counterId === group.storageCounter)
80+
const usedStorage = Number(userStorageCounterValue?.value ?? "0")
81+
return {
82+
name: info.name,
83+
mailAddress: info.mailAddress ?? "",
84+
created: info.created,
85+
deleted: info.deleted,
86+
usedStorage,
87+
aliases: info.mailAddressAliases.map((alias) => alias.mailAddress),
88+
}
89+
})
90+
91+
completed += mapped.length
92+
93+
// in case we did not get every single group back that we requested (something may have changed in the meantime)
94+
total -= infos.length - mapped.length
95+
96+
onProgress?.(completed, total)
97+
98+
return mapped
6699
})
100+
101+
return downloaded.flat()
67102
}

src/mail-app/settings/SettingsView.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { GroupListView } from "./groups/GroupListView.js"
1717
import { WhitelabelSettingsViewer } from "../../common/settings/whitelabel/WhitelabelSettingsViewer"
1818
import { Icons } from "../../common/gui/base/icons/Icons"
1919
import { theme } from "../../common/gui/theme"
20-
import { FeatureType, GroupType, LegacyPlans } from "../../common/api/common/TutanotaConstants"
20+
import { FeatureType, GroupType } from "../../common/api/common/TutanotaConstants"
2121
import { BootIcons } from "../../common/gui/base/icons/BootIcons"
2222
import { locator } from "../../common/api/main/CommonLocator"
2323
import { SubscriptionViewer } from "../../common/subscription/SubscriptionViewer"
@@ -44,7 +44,7 @@ import { showProgressDialog } from "../../common/gui/dialogs/ProgressDialog"
4444
import { createGroupSettings, createUserAreaGroupDeleteData, UserSettingsGroupRootTypeRef } from "../../common/api/entities/tutanota/TypeRefs.js"
4545
import { GroupInvitationFolderRow } from "../../common/sharing/view/GroupInvitationFolderRow"
4646
import { TemplateGroupService } from "../../common/api/entities/tutanota/Services"
47-
import { exportUserCsv } from "../../common/settings/UserDataExporter.js"
47+
import { exportUserCsv, loadUserExportData } from "../../common/settings/UserDataExporter.js"
4848
import { IconButton } from "../../common/gui/base/IconButton.js"
4949
import { BottomNav } from "../gui/BottomNav.js"
5050
import { getAvailableDomains } from "../../common/settings/mailaddress/MailAddressesUtils.js"
@@ -78,6 +78,8 @@ import { MailExportViewer } from "./MailExportViewer"
7878
import { getSupportUsageTestStage } from "../../common/support/SupportUsageTestUtils.js"
7979
import { LockedError } from "../../common/api/common/error/RestError"
8080
import { shouldHideBusinessPlans } from "../../common/subscription/SubscriptionUtils"
81+
import { ButtonType } from "../../common/gui/base/Button"
82+
import { CancelledError } from "../../common/api/common/error/CancelledError"
8183

8284
assertMainOrNode()
8385

@@ -404,7 +406,6 @@ export class SettingsView extends BaseTopLevelView implements TopLevelView<Setti
404406
await this.updateShowBusinessSettings()
405407
await this.updateShowAffiliateSettings()
406408
const currentPlanType = await this.logins.getUserController().getPlanType()
407-
const isLegacyPlan = LegacyPlans.includes(currentPlanType)
408409

409410
if (await this.logins.getUserController().canHaveUsers()) {
410411
this._adminFolders.push(
@@ -418,7 +419,7 @@ export class SettingsView extends BaseTopLevelView implements TopLevelView<Setti
418419
() => this.focusSettingsDetailsColumn(),
419420
() => !isApp() && this._customDomains.isLoaded() && this._customDomains.getLoaded().length > 0,
420421
() => showUserImportDialog(this._customDomains.getLoaded()),
421-
() => exportUserCsv(locator.entityClient, this.logins, locator.fileController, locator.counterFacade),
422+
() => this.doExportUsers(),
422423
),
423424
undefined,
424425
),
@@ -932,6 +933,48 @@ export class SettingsView extends BaseTopLevelView implements TopLevelView<Setti
932933
const customer = await this.logins.getUserController().loadCustomer()
933934
this.showAffiliateSettings = isCustomizationEnabledForCustomer(customer, FeatureType.AffiliatePartner)
934935
}
936+
937+
private async doExportUsers() {
938+
try {
939+
const progress = stream(0)
940+
let progressText = lang.getTranslation("pleaseWait_msg")
941+
const abortController = new AbortController()
942+
943+
const data = await showProgressDialog(
944+
() => progressText,
945+
loadUserExportData(
946+
locator.entityClient,
947+
this.logins,
948+
locator.counterFacade,
949+
(complete: number, total: number) => {
950+
progressText = lang.getTranslation("userExportProgress_msg", {
951+
"{current}": complete,
952+
"{total}": total,
953+
})
954+
progress((complete / total) * 100)
955+
},
956+
abortController.signal,
957+
),
958+
progress,
959+
{
960+
middle: "exportUsers_action",
961+
left: [
962+
{
963+
label: "cancel_action",
964+
type: ButtonType.Primary,
965+
click: () => abortController.abort(),
966+
},
967+
],
968+
},
969+
)
970+
await exportUserCsv(data, locator.fileController)
971+
} catch (e) {
972+
if (e instanceof CancelledError) {
973+
return
974+
}
975+
throw e
976+
}
977+
}
935978
}
936979

937980
function showRenameTemplateListDialog(instance: TemplateGroupInstance) {

src/mail-app/translations/de.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2062,5 +2062,6 @@ export default {
20622062
"unread_label": "Unread",
20632063
"replied_label": "Replied",
20642064
"forwarded_label": "Forwarded",
2065-
}
2065+
"userExportProgress_msg": "Preparing {current} of {total} user(s) for export...",
2066+
}
20662067
}

src/mail-app/translations/de_sie.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,5 +2062,6 @@ export default {
20622062
"unread_label": "Unread",
20632063
"replied_label": "Replied",
20642064
"forwarded_label": "Forwarded",
2065+
"userExportProgress_msg": "Preparing {current} of {total} user(s) for export...",
20652066
}
20662067
}

src/mail-app/translations/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,5 +2058,6 @@ export default {
20582058
"unread_label": "Unread",
20592059
"replied_label": "Replied",
20602060
"forwarded_label": "Forwarded",
2061+
"userExportProgress_msg": "Preparing {current} of {total} user(s) for export...",
20612062
}
20622063
}

test/tests/settings/UserDataExportTest.ts

Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import o from "@tutao/otest"
22
import { loadUserExportData } from "../../../src/common/settings/UserDataExporter.js"
33
import { EntityClient } from "../../../src/common/api/common/EntityClient.js"
44
import { LoginController } from "../../../src/common/api/main/LoginController.js"
5-
import { FileController } from "../../../src/common/file/FileController.js"
6-
import { object, when } from "testdouble"
5+
import { matchers, object, when } from "testdouble"
76
import { CustomerTypeRef, Group, GroupInfo, GroupInfoTypeRef, GroupTypeRef, User } from "../../../src/common/api/entities/sys/TypeRefs.js"
87
import { formatDateTimeUTC } from "../../../src/calendar-app/calendar/export/CalendarExporter.js"
98
import { CounterFacade } from "../../../src/common/api/worker/facades/lazy/CounterFacade.js"
109
import { CounterType } from "../../../src/common/api/common/TutanotaConstants.js"
1110
import { CounterValueTypeRef } from "../../../src/common/api/entities/monitor/TypeRefs.js"
1211
import { createTestEntity } from "../TestUtils.js"
12+
import { TypeRef } from "@tutao/tutanota-utils"
1313

1414
o.spec("user data export", function () {
1515
const customerId = "customerId"
@@ -19,34 +19,42 @@ o.spec("user data export", function () {
1919
customer: customerId,
2020
} as User
2121

22-
let allUserGroupInfos: Array<GroupInfo>
22+
let allUserGroupInfos: GroupInfo[]
2323

2424
let entityClientMock: EntityClient
2525
let counterFacadeMock: CounterFacade
2626
let loginsMock: LoginController
27-
let fileControllerMock: FileController
27+
let allGroups: Group[]
2828

2929
o.beforeEach(function () {
3030
allUserGroupInfos = []
3131

3232
loginsMock = object()
3333
when(loginsMock.getUserController()).thenReturn({
34-
user,
34+
loadCustomer: () =>
35+
Promise.resolve(
36+
createTestEntity(CustomerTypeRef, {
37+
userGroups: userGroupsId,
38+
_id: customerId,
39+
}),
40+
),
3541
// we only test the case where we are global admin for now
3642
isGlobalAdmin: () => true,
3743
})
3844

3945
entityClientMock = object()
40-
when(entityClientMock.load(CustomerTypeRef, customerId)).thenResolve({
41-
userGroups: userGroupsId,
42-
})
4346
when(entityClientMock.loadAll(GroupInfoTypeRef, userGroupsId)).thenResolve(allUserGroupInfos)
47+
when(entityClientMock.loadMultiple(GroupTypeRef, null, matchers.anything())).thenDo(
48+
(_typeref: TypeRef<Group>, _list: Id | null, groups: readonly Id[]) => {
49+
return Promise.resolve(allGroups.filter((g) => groups.includes(g._id)))
50+
},
51+
)
4452

4553
counterFacadeMock = object()
46-
fileControllerMock = object()
54+
allGroups = []
4755
})
4856

49-
o("should load and return correct user data ", async function () {
57+
o.test("should load and return correct user data ", async function () {
5058
const oneCreated = new Date(1655294400000) // 2022-06-15 12:00:00 GMT+0
5159
const oneDeleted = new Date(1655469000000) // "2022-06-17 12:30:00 GMT +0"
5260
const twoCreated = new Date(1657886400000) // "2022-07-15 12:00:00 GMT+0"
@@ -59,38 +67,60 @@ o.spec("user data export", function () {
5967
// missing counter for second user!
6068
])
6169

62-
const [first, second] = await loadUserExportData(entityClientMock, loginsMock, counterFacadeMock)
70+
let onProgressLastComplete: number | undefined
71+
let onProgressLastTotal: number | undefined
72+
let onProgressCalledTimes = 0
73+
74+
const [first, second] = await loadUserExportData(entityClientMock, loginsMock, counterFacadeMock, (complete, total) => {
75+
if (onProgressLastComplete != null) {
76+
o.check(complete > onProgressLastComplete).equals(true)
77+
} else {
78+
o.check(complete).equals(0)
79+
}
80+
if (onProgressLastTotal != null) {
81+
o.check(total).equals(onProgressLastTotal)
82+
}
83+
onProgressLastComplete = complete
84+
onProgressLastTotal = total
85+
onProgressCalledTimes += 1
86+
})
87+
88+
o.check(first.name).equals("my name")
89+
o.check(second.name).equals("eman ym")
6390

64-
o(first.name).equals("my name")
65-
o(second.name).equals("eman ym")
91+
o.check(first.mailAddress).equals("[email protected]")
92+
o.check(second.mailAddress).equals("[email protected]")
6693

67-
o(first.mailAddress).equals("[email protected]")
68-
o(second.mailAddress).equals("[email protected]")
94+
o.check(formatDateTimeUTC(first.created)).equals("20220615T120000Z")
95+
o.check(formatDateTimeUTC(second.created)).equals("20220715T120000Z")
6996

70-
o(formatDateTimeUTC(first.created)).equals("20220615T120000Z")
71-
o(formatDateTimeUTC(second.created)).equals("20220715T120000Z")
97+
o.check(formatDateTimeUTC(first.deleted!)).equals("20220617T123000Z")
98+
o.check(second.deleted).equals(null)
7299

73-
o(formatDateTimeUTC(first.deleted!)).equals("20220617T123000Z")
74-
o(second.deleted).equals(null)
100+
o.check(first.usedStorage).equals(100)
101+
o.check(second.usedStorage).equals(0)
75102

76-
o(first.usedStorage).equals(100)
77-
o(second.usedStorage).equals(0)
103+
o.check(first.aliases).deepEquals(["[email protected]", "[email protected]"])
104+
o.check(second.aliases).deepEquals([])
78105

79-
o(first.aliases).deepEquals(["[email protected]", "[email protected]"])
80-
o(second.aliases).deepEquals([])
106+
o.check(onProgressLastComplete).equals(2)
107+
o.check(onProgressLastTotal).equals(2)
108+
o.check(onProgressCalledTimes).equals(2)
81109
})
82110

83111
function addUser(name, mailAddress, created, deleted, usedStorage, aliases, userId, groupId, storageCounterId) {
84-
allUserGroupInfos.push({
85-
name,
86-
mailAddress,
87-
created,
88-
deleted,
89-
mailAddressAliases: aliases.map((alias) => ({ mailAddress: alias })),
90-
group: groupId,
91-
} as GroupInfo)
92-
93-
const group = { storageCounter: storageCounterId } as Group
94-
when(entityClientMock.load(GroupTypeRef, groupId)).thenResolve({ storageCounter: group.storageCounter })
112+
allUserGroupInfos.push(
113+
createTestEntity(GroupInfoTypeRef, {
114+
name,
115+
mailAddress,
116+
created,
117+
deleted,
118+
mailAddressAliases: aliases.map((alias) => ({ mailAddress: alias })),
119+
group: groupId,
120+
}),
121+
)
122+
123+
const group = createTestEntity(GroupTypeRef, { storageCounter: storageCounterId, _id: groupId })
124+
allGroups.push(group)
95125
}
96126
})

0 commit comments

Comments
 (0)