diff --git a/i18n/en.pot b/i18n/en.pot index 759b1931..23f910cb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,32 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-06-26T10:58:19.149Z\n" -"PO-Revision-Date: 2024-06-26T10:58:19.149Z\n" +"POT-Creation-Date: 2024-10-01T18:51:44.288Z\n" +"PO-Revision-Date: 2024-10-01T18:51:44.288Z\n" + +msgid "edit dataset" +msgstr "" + +msgid "create new dataset" +msgstr "" + +msgid "change sharing settings" +msgstr "" + +msgid "change organisation units" +msgstr "" + +msgid "clone dataset" +msgstr "" + +msgid "No public access" +msgstr "" + +msgid "Public view/edit" +msgstr "" + +msgid "Public view" +msgstr "" msgid "Add" msgstr "" @@ -14,17 +38,140 @@ msgstr "" msgid "List" msgstr "" +msgid "Are you sure you want to delete this/those dataset(s)?" +msgstr "" + +msgid "This action cannot be undone." +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Loading logs..." +msgstr "" + +msgid "Logs" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Date" +msgstr "" + +msgid "Action" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Datasets" +msgstr "" + +msgid "Id" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "Access" +msgstr "" + +msgid "Last updated" +msgstr "" + +msgid "Short Name" +msgstr "" + +msgid "Description" +msgstr "" + +msgid "Created" +msgstr "" + +msgid "ID" +msgstr "" + +msgid "Core competencies" +msgstr "" + +msgid "Sharing" +msgstr "" + +msgid "Edit" +msgstr "" + +msgid "Sharing Settings" +msgstr "" + +msgid "Assign to Organisation Units" +msgstr "" + +msgid "Set output/outcome period dates" +msgstr "" + +msgid "Change output/outcome end date for year" +msgstr "" + +msgid "Details" +msgstr "" + +msgid "Clone" +msgstr "" + +msgid "Search" +msgstr "" + +msgid "Removing DataSets" +msgstr "" + +msgid "DataSets removed" +msgstr "" + +msgid "Replace" +msgstr "" + +msgid "Merge" +msgstr "" + +msgid "Organisation Units" +msgstr "" + +msgid "Save" +msgstr "" + +msgid "Bulk update strategy: {{actionLabel}}" +msgstr "" + +msgid "Saving sharing settings..." +msgstr "" + +msgid "Sharing settings saved" +msgstr "" + msgid "Back" msgstr "" msgid "Help" msgstr "" -msgid "Hello {{name}}" +msgid "Public Access" msgstr "" -msgid "Detail page" +msgid "User access" msgstr "" -msgid "Section" +msgid "Groups" +msgstr "" + +msgid "Hello {{name}}" +msgstr "" + +msgid "Detail page" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 416c26cb..6555b69a 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,30 +1,178 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-06-26T10:58:19.149Z\n" +"POT-Creation-Date: 2024-10-01T18:51:44.288Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "edit dataset" +msgstr "" + +msgid "create new dataset" +msgstr "" + +msgid "change sharing settings" +msgstr "" + +msgid "change organisation units" +msgstr "" + +msgid "clone dataset" +msgstr "" + +msgid "No public access" +msgstr "" + +msgid "Public view/edit" +msgstr "" + +msgid "Public view" +msgstr "" + msgid "Add" msgstr "Añadir" msgid "List" msgstr "Listar" +msgid "Are you sure you want to delete this/those dataset(s)?" +msgstr "" + +msgid "This action cannot be undone." +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Loading logs..." +msgstr "" + +msgid "Logs" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Date" +msgstr "" + +msgid "Action" +msgstr "" + +msgid "Status" +msgstr "" + +msgid "User" +msgstr "" + +msgid "Datasets" +msgstr "" + +msgid "Id" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "Access" +msgstr "" + +msgid "Last updated" +msgstr "" + +msgid "Short Name" +msgstr "" + +#, fuzzy +msgid "Description" +msgstr "Sección" + +msgid "Created" +msgstr "" + +msgid "ID" +msgstr "" + +msgid "Core competencies" +msgstr "" + +msgid "Sharing" +msgstr "" + +msgid "Edit" +msgstr "" + +msgid "Sharing Settings" +msgstr "" + +msgid "Assign to Organisation Units" +msgstr "" + +msgid "Set output/outcome period dates" +msgstr "" + +msgid "Change output/outcome end date for year" +msgstr "" + +msgid "Details" +msgstr "" + +msgid "Clone" +msgstr "" + +msgid "Search" +msgstr "" + +msgid "Removing DataSets" +msgstr "" + +msgid "DataSets removed" +msgstr "" + +msgid "Replace" +msgstr "" + +msgid "Merge" +msgstr "" + +msgid "Organisation Units" +msgstr "" + +msgid "Save" +msgstr "" + +msgid "Bulk update strategy: {{actionLabel}}" +msgstr "" + +msgid "Saving sharing settings..." +msgstr "" + +msgid "Sharing settings saved" +msgstr "" + msgid "Back" msgstr "Volver" msgid "Help" msgstr "Ayuda" +msgid "Public Access" +msgstr "" + +msgid "User access" +msgstr "" + +msgid "Groups" +msgstr "" + msgid "Hello {{name}}" msgstr "Hola {{name}}" msgid "Detail page" msgstr "" - -msgid "Section" -msgstr "Sección" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index c6eda97d..a86393d5 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -1,3 +1,19 @@ +import { DataSetD2Repository } from "$/data/repositories/DataSetD2Repository"; +import { DataSetTestRepository } from "$/data/repositories/DataSetTestRepository"; +import { LogD2Repository } from "$/data/repositories/LogD2Repository"; +import { LogTestRepository } from "$/data/repositories/LogTestRepository"; +import { SharingD2Repository } from "$/data/repositories/SharingD2Repository"; +import { SharingRepository } from "$/data/repositories/SharingRepository"; +import { SharingTestRepository } from "$/data/repositories/SharingTestRepository"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; +import { LogRepository } from "$/domain/repositories/LogRepository"; +import { GetDataSetsByIdsUseCase } from "$/domain/usecases/GetDataSetsByIdsUseCase"; +import { GetDataSetsUseCase } from "$/domain/usecases/GetDataSetsUseCase"; +import { GetLogsUseCase } from "$/domain/usecases/GetLogsUseCase"; +import { RemoveDataSetsUseCase } from "$/domain/usecases/RemoveDataSetsUseCase"; +import { SaveOrgUnitDataSetUseCase } from "$/domain/usecases/SaveOrgUnitDataSetUseCase"; +import { SaveSharingDataSetsUseCase } from "$/domain/usecases/SaveSharingDataSetsUseCase"; +import { SearchSharingUseCase } from "$/domain/usecases/SearchSharingUseCase"; import { UserD2Repository } from "./data/repositories/UserD2Repository"; import { UserTestRepository } from "./data/repositories/UserTestRepository"; import { UserRepository } from "./domain/repositories/UserRepository"; @@ -7,20 +23,40 @@ import { D2Api } from "./types/d2-api"; export type CompositionRoot = ReturnType; type Repositories = { + sharingRepository: SharingRepository; usersRepository: UserRepository; + dataSetsRepository: DataSetRepository; + logRepository: LogRepository; }; function getCompositionRoot(repositories: Repositories) { return { - users: { - getCurrent: new GetCurrentUserUseCase(repositories.usersRepository), + dataSets: { + getByIds: new GetDataSetsByIdsUseCase(repositories.dataSetsRepository), + getAll: new GetDataSetsUseCase(repositories.dataSetsRepository), + remove: new RemoveDataSetsUseCase(repositories.dataSetsRepository), + save: new SaveSharingDataSetsUseCase(repositories.dataSetsRepository), + saveOrgUnits: new SaveOrgUnitDataSetUseCase(repositories.dataSetsRepository), }, + logs: { + getByDataSets: new GetLogsUseCase( + repositories.dataSetsRepository, + repositories.logRepository + ), + }, + sharing: { + search: new SearchSharingUseCase(repositories.sharingRepository), + }, + users: { getCurrent: new GetCurrentUserUseCase(repositories.usersRepository) }, }; } export function getWebappCompositionRoot(api: D2Api) { const repositories: Repositories = { usersRepository: new UserD2Repository(api), + dataSetsRepository: new DataSetD2Repository(api), + sharingRepository: new SharingD2Repository(api), + logRepository: new LogD2Repository(api), }; return getCompositionRoot(repositories); @@ -29,6 +65,9 @@ export function getWebappCompositionRoot(api: D2Api) { export function getTestCompositionRoot() { const repositories: Repositories = { usersRepository: new UserTestRepository(), + dataSetsRepository: new DataSetTestRepository(), + sharingRepository: new SharingTestRepository(), + logRepository: new LogTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/D2ApiLogs.ts b/src/data/D2ApiLogs.ts new file mode 100644 index 00000000..dbeb522b --- /dev/null +++ b/src/data/D2ApiLogs.ts @@ -0,0 +1,90 @@ +import { FutureData, apiToFuture } from "$/data/api-futures"; +import { Log } from "$/domain/entities/Log"; +import { ISODateString, Id } from "$/domain/entities/Ref"; +import { Future } from "$/domain/entities/generic/Future"; +import { GetLogsOptions } from "$/domain/repositories/LogRepository"; +import { D2Api } from "$/types/d2-api"; +import i18n from "$/utils/i18n"; +import { DataStore } from "@eyeseetea/d2-api/api"; + +const LOGS_NAMESPACE = "dataset-configuration"; +const LOGS_PAGE_CURRENT_KEY = "logs-page-current"; +const LOGS_PAGE_PREFIX = "logs-page-"; + +export class D2ApiLogs { + private dataStore: DataStore; + constructor(private api: D2Api) { + this.dataStore = this.api.dataStore(LOGS_NAMESPACE); + } + + getByDate(options: GetLogsOptions): FutureData { + return this.getCurrentPage().flatMap(currentPage => { + return apiToFuture(this.dataStore.get(LOGS_PAGE_PREFIX + currentPage)).map( + d2Logs => { + if (!d2Logs) return []; + const logs = d2Logs?.map(d2Log => this.buildLog(d2Log)); + return logs.filter(log => + log.dataSets.some(dataset => options.dataSetsIds?.includes(dataset.id)) + ); + } + ); + }); + } + + private buildLog(d2Log: D2Logs): Log { + const action = this.buildActionFromLegacyDescription(d2Log.action); + return Log.create({ + actionDescription: action.description, + action: action.action, + date: d2Log.date, + status: d2Log.status, + dataSets: d2Log.datasets.map(ds => ({ id: ds.id, shortName: "" })), + type: "dataSets", + user: { + id: d2Log.user.id, + name: d2Log.user.displayName, + username: d2Log.user.username, + }, + }); + } + + private buildActionFromLegacyDescription(description: string): D2LegacyAction { + const action = legacyActionsNames[description]; + if (!action) return { description: "unknown action", action: "unknown" }; + return action; + } + + private getCurrentPage(): FutureData { + return apiToFuture(this.dataStore.get(LOGS_PAGE_CURRENT_KEY)).flatMap( + currentPage => { + if (!currentPage) return Future.error(new Error("Error getting logs current page")); + return Future.success(currentPage); + } + ); + } +} + +export type D2LogCurrentPage = number; +export type D2Logs = { + action: string; + datasets: Array<{ id: Id }>; + date: ISODateString; + status: Log["status"]; + user: { displayName: string; id: Id; username: string }; +}; + +export type D2LegacyAction = { action: Log["action"]; description: string }; +const legacyActionsNames: Record = { + "edit dataset": { action: "edit", description: i18n.t("edit dataset") }, + "create new dataset": { action: "create", description: i18n.t("create new dataset") }, + "change sharing settings": { + action: "sharing", + description: i18n.t("change sharing settings"), + }, + delete: { action: "delete", description: "delete" }, + "change organisation units": { + action: "orgunits", + description: i18n.t("change organisation units"), + }, + "clone dataset": { action: "clone", description: i18n.t("clone dataset") }, +}; diff --git a/src/data/repositories/DataSetD2Repository.ts b/src/data/repositories/DataSetD2Repository.ts new file mode 100644 index 00000000..cfdaae5d --- /dev/null +++ b/src/data/repositories/DataSetD2Repository.ts @@ -0,0 +1,317 @@ +import { D2Api, MetadataPick } from "$/types/d2-api"; +import { FutureData, apiToFuture } from "$/data/api-futures"; +import { AccessData, AccessType, CoreCompetency, DataSet } from "$/domain/entities/DataSet"; +import { Paginated } from "$/domain/entities/Paginated"; +import { DataSetRepository, GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; +import { Future } from "$/domain/entities/generic/Future"; +import { Maybe } from "$/utils/ts-utils"; +import { Id, OctalNotationPermission } from "$/domain/entities/Ref"; +import { getUid } from "$/utils/uid"; +import { Permission } from "$/domain/entities/Permission"; +import _ from "$/domain/entities/generic/Collection"; + +export class DataSetD2Repository implements DataSetRepository { + constructor(private api: D2Api) {} + + get(options: GetDataSetOptions): FutureData> { + return this.getBaseData().flatMap(({ attributeData, coreCompetencies }) => { + return apiToFuture( + this.api.models.dataSets.get({ + pageSize: options.paging.pageSize, + page: options.paging.page, + filter: { + "attributeValues.attribute.id": { eq: attributeData.id }, + "attributeValues.value": { eq: "true" }, + identifiable: { token: options.filters.search }, + id: { in: options.filters.ids }, + }, + fields: dataSetFields, + order: `${options.sorting.field}:${options.sorting.order}`, + }) + ).map(d2Response => { + const dataSets = d2Response.objects.map(d2DataSet => { + return this.buildDataSet(d2DataSet, coreCompetencies); + }); + return { ...d2Response.pager, data: dataSets }; + }); + }); + } + + getByIds(ids: string[]): FutureData { + if (ids.length === 0) return Future.success([]); + return this.getBaseData().flatMap(({ attributeData, coreCompetencies }) => { + const $requests = Future.sequential( + _(ids) + .chunk(50) + .map(dataSetIds => { + return apiToFuture( + this.api.models.dataSets.get({ + paging: false, + filter: { + "attributeValues.attribute.id": { eq: attributeData.id }, + "attributeValues.value": { eq: "true" }, + id: { in: dataSetIds }, + }, + fields: { + ...dataSetFields, + organisationUnits: { id: true, displayName: true, path: true }, + }, + }) + ); + }) + .value() + ); + + return $requests.map(d2Response => { + const allDataSets = d2Response.flatMap(d2Response => d2Response.objects); + const dataSets = allDataSets.map(d2DataSet => { + return this.buildDataSet(d2DataSet, coreCompetencies); + }); + return dataSets; + }); + }); + } + + save(dataSets: DataSet[]): FutureData { + if (dataSets.length === 0) return Future.success(undefined); + const dataSetsIds = dataSets.map(dataSet => dataSet.id); + + const $requests = Future.sequential( + _(dataSetsIds) + .chunk(50) + .map(dataSetIds => { + return apiToFuture( + this.api.models.dataSets.get({ + fields: { $owner: true }, + filter: { id: { in: dataSetIds } }, + paging: false, + }) + ).flatMap(d2Response => { + const dataSetsToSave = dataSetIds.map(dataSetId => { + const existingDataSet = d2Response.objects.find( + ds => ds.id === dataSetId + ); + const dataSet = dataSets.find(dataSet => dataSet.id === dataSetId); + if (!dataSet) { + throw Error(`Cannot find dataSet: ${dataSetId}`); + } + + const result = { + ...(existingDataSet || {}), + ...this.buildD2DataSet(dataSet), + }; + delete result.sharing; + return result; + }); + + return apiToFuture(this.api.metadata.post({ dataSets: dataSetsToSave })); + }); + }) + .value() + ); + + return $requests.toVoid(); + } + + remove(ids: string[]): FutureData { + if (ids.length === 0) return Future.success(undefined); + const $requests = Future.sequential( + _(ids) + .chunk(50) + .map(dataSetIds => { + return apiToFuture( + this.api.metadata.post( + { + dataSets: dataSetIds.map(id => ({ id })), + }, + { + importStrategy: "DELETE", + } + ) + ); + }) + .value() + ); + + return $requests.toVoid(); + } + + private getBaseData(): FutureData<{ + attributeData: { id: Id }; + coreCompetencies: CoreCompetency[]; + }> { + const attributeCode = "GL_CREATED_BY_DATASET_CONFIGURATION"; + return Future.joinObj({ + attributeData: this.getAttributeByCode(attributeCode), + coreCompetencies: this.getAllCompetencies(), + }); + } + + private buildD2DataSet(dataSet: DataSet) { + return { + id: dataSet.id || getUid(dataSet.name), + name: dataSet.name, + description: dataSet.description, + shortName: dataSet.shortName, + publicAccess: DataSet.generateFullPermission(dataSet), + userAccesses: _(dataSet.access) + .map(access => { + if (access.type !== "users") return undefined; + return { access: access.value, id: access.id, displayName: access.name }; + }) + .compact() + .value(), + userGroupAccesses: _(dataSet.access) + .map(access => { + if (access.type !== "groups") return undefined; + return { access: access.value, id: access.id, displayName: access.name }; + }) + .compact() + .value(), + organisationUnits: dataSet.orgUnits.map(ou => ({ id: ou.id })), + }; + } + + private getAllCompetencies(): FutureData { + const dataElementGroupCode = "GL_CoreComp_DEGROUPSET"; + return apiToFuture( + this.api.models.dataElementGroupSets.get({ + fields: { + id: true, + dataElementGroups: { id: true, displayName: true, code: true }, + }, + filter: { code: { eq: dataElementGroupCode } }, + paging: false, + }) + ).flatMap(d2Response => { + const degSet = d2Response.objects[0]; + if (!degSet) { + return Future.error( + new Error(`DataElementGroupSet with code ${dataElementGroupCode} not found`) + ); + } + return Future.success( + degSet.dataElementGroups.map(deg => { + return { id: deg.id, name: deg.displayName, code: deg.code }; + }) + ); + }); + } + + private buildDataElementsGroupsCodes(d2DataSet: D2DataSet): string[] { + const dataElementsGroupsCodes = _(d2DataSet.sections) + .map((section): Maybe => { + return this.extractCompentencyCode(section.id, section.code); + }) + .compact() + .value(); + + return _(dataElementsGroupsCodes).uniq().value(); + } + + private getAttributeByCode(code: string): FutureData<{ id: string }> { + return apiToFuture( + this.api.models.attributes.get({ + fields: { id: true }, + filter: { code: { eq: code } }, + }) + ).flatMap(d2Response => { + const d2Attribute = d2Response.objects[0]; + if (!d2Attribute) { + return Future.error(new Error(`Attribute with code ${code} not found`)); + } + return Future.success({ id: d2Attribute.id }); + }); + } + + private buildDataSet(d2DataSet: D2DataSet, coreCompetencies: CoreCompetency[]): DataSet { + const dataElementGroups = this.buildDataElementsGroupsCodes(d2DataSet); + return DataSet.create({ + orgUnits: d2DataSet.organisationUnits + ? d2DataSet.organisationUnits.map(ou => { + return { + id: ou.id, + name: ou.displayName, + paths: ou.path.split("/").slice(1), + }; + }) + : [], + created: d2DataSet.created, + description: d2DataSet.displayDescription, + id: d2DataSet.id, + name: d2DataSet.displayName, + lastUpdated: d2DataSet.lastUpdated, + dataPermissions: this.buildPermission(d2DataSet.sharing.public, "data"), + metadataPermissions: this.buildPermission(d2DataSet.sharing.public, "metadata"), + shortName: d2DataSet.displayShortName, + access: this.buildAccessByType(d2DataSet.userAccesses, "users").concat( + this.buildAccessByType(d2DataSet.userGroupAccesses, "groups") + ), + coreCompetencies: _(dataElementGroups) + .map(degCode => { + const compentency = coreCompetencies.find(cc => cc.code === degCode); + if (!compentency) return undefined; + return { id: compentency.id, name: compentency.name, code: compentency.code }; + }) + .compact() + .value(), + }); + } + + private buildAccessByType( + accessData: Array<{ id: Id; displayName: string; access: OctalNotationPermission }>, + type: AccessType + ): AccessData[] { + return accessData.map((access): AccessData => { + return { id: access.id, name: access.displayName, value: access.access, type }; + }); + } + + private extractCompentencyCode(sectionId: Id, sectionCode: string): string { + if (!sectionCode) { + console.error(`Section has not code: ${sectionId}`); + return ""; + } + const [_prefix, _type, ...ccCodeParts] = sectionCode.split("_"); + return ccCodeParts.join("_"); + } + + private buildPermission(permissions: string, permissionType: "data" | "metadata"): Permission { + if (permissionType === "metadata") { + const { canRead, canWrite } = this.buildPermissionByType(permissions, permissionType); + return { canRead, canWrite, noAccess: !canWrite && !canRead }; + } else if (permissionType === "data") { + const { canWrite, canRead } = this.buildPermissionByType(permissions, permissionType); + return { canRead, canWrite, noAccess: !canWrite && !canRead }; + } else { + throw new Error("Invalid type"); + } + } + + private buildPermissionByType(permissions: string, permissionType: "data" | "metadata") { + const initialIndex = permissionType === "metadata" ? 0 : 2; + const canRead = permissions[initialIndex] === "r"; + const canWrite = permissions[initialIndex + 1] === "w"; + return { canRead, canWrite }; + } +} + +const dataSetFields = { + created: true, + displayDescription: true, + displayName: true, + id: true, + lastUpdated: true, + sharing: { public: true }, + displayShortName: true, + sections: { id: true, displayName: true, code: true }, + userGroupAccesses: { id: true, displayName: true, access: true }, + userAccesses: { id: true, displayName: true, access: true }, +} as const; + +type D2DataSetFields = MetadataPick<{ + dataSets: { fields: typeof dataSetFields }; +}>["dataSets"][number]; + +type D2DataSet = { organisationUnits?: D2OrgUnit[] } & D2DataSetFields; +type D2OrgUnit = { id: Id; path: string; displayName: string }; diff --git a/src/data/repositories/DataSetTestRepository.ts b/src/data/repositories/DataSetTestRepository.ts new file mode 100644 index 00000000..e8a321c2 --- /dev/null +++ b/src/data/repositories/DataSetTestRepository.ts @@ -0,0 +1,20 @@ +import { FutureData } from "$/data/api-futures"; +import { DataSet } from "$/domain/entities/DataSet"; +import { Paginated } from "$/domain/entities/Paginated"; +import { Future } from "$/domain/entities/generic/Future"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; + +export class DataSetTestRepository implements DataSetRepository { + getByIds(): FutureData { + throw new Error("Method not implemented."); + } + save(): FutureData { + throw new Error("Method not implemented."); + } + remove(): FutureData { + throw new Error("Method not implemented."); + } + get(): FutureData> { + return Future.success({ data: [], page: 1, pageCount: 1, total: 0, pageSize: 10 }); + } +} diff --git a/src/data/repositories/LogD2Repository.ts b/src/data/repositories/LogD2Repository.ts new file mode 100644 index 00000000..8615fc7b --- /dev/null +++ b/src/data/repositories/LogD2Repository.ts @@ -0,0 +1,16 @@ +import { D2ApiLogs } from "$/data/D2ApiLogs"; +import { FutureData } from "$/data/api-futures"; +import { Log } from "$/domain/entities/Log"; +import { GetLogsOptions, LogRepository } from "$/domain/repositories/LogRepository"; +import { D2Api } from "$/types/d2-api"; + +export class LogD2Repository implements LogRepository { + private d2ApiLogs: D2ApiLogs; + constructor(private api: D2Api) { + this.d2ApiLogs = new D2ApiLogs(this.api); + } + + getByDataSets(options: GetLogsOptions): FutureData { + return this.d2ApiLogs.getByDate(options); + } +} diff --git a/src/data/repositories/LogTestRepository.ts b/src/data/repositories/LogTestRepository.ts new file mode 100644 index 00000000..3c527e00 --- /dev/null +++ b/src/data/repositories/LogTestRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "$/data/api-futures"; +import { Log } from "$/domain/entities/Log"; +import { LogRepository } from "$/domain/repositories/LogRepository"; + +export class LogTestRepository implements LogRepository { + getByDataSets(): FutureData { + throw new Error("Method not implemented."); + } +} diff --git a/src/data/repositories/SharingD2Repository.ts b/src/data/repositories/SharingD2Repository.ts new file mode 100644 index 00000000..fdf8c37d --- /dev/null +++ b/src/data/repositories/SharingD2Repository.ts @@ -0,0 +1,22 @@ +import { D2Api } from "$/types/d2-api"; +import { FutureData, apiToFuture } from "$/data/api-futures"; +import { SharingRepository } from "$/data/repositories/SharingRepository"; +import { Sharing } from "$/domain/entities/Sharing"; + +export class SharingD2Repository implements SharingRepository { + constructor(private api: D2Api) {} + + getBy(search: string): FutureData { + return apiToFuture(this.api.sharing.search({ key: search })).map(d2Response => { + return Sharing.create({ + publicAccess: "", + userAccesses: d2Response.users.map(user => { + return { id: user.id, name: user.displayName }; + }), + userGroupAccesses: d2Response.userGroups.map(userGroup => { + return { id: userGroup.id, name: userGroup.displayName }; + }), + }); + }); + } +} diff --git a/src/data/repositories/SharingRepository.ts b/src/data/repositories/SharingRepository.ts new file mode 100644 index 00000000..3d70d899 --- /dev/null +++ b/src/data/repositories/SharingRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "$/data/api-futures"; +import { Sharing } from "$/domain/entities/Sharing"; + +export interface SharingRepository { + getBy(search: string): FutureData; +} diff --git a/src/data/repositories/SharingTestRepository.ts b/src/data/repositories/SharingTestRepository.ts new file mode 100644 index 00000000..d7b1ffc8 --- /dev/null +++ b/src/data/repositories/SharingTestRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "$/data/api-futures"; +import { SharingRepository } from "$/data/repositories/SharingRepository"; +import { Sharing } from "$/domain/entities/Sharing"; + +export class SharingTestRepository implements SharingRepository { + getBy(): FutureData { + throw new Error("Method not implemented."); + } +} diff --git a/src/domain/entities/DataSet.ts b/src/domain/entities/DataSet.ts new file mode 100644 index 00000000..1d6cd825 --- /dev/null +++ b/src/domain/entities/DataSet.ts @@ -0,0 +1,61 @@ +import { Permission } from "$/domain/entities/Permission"; +import { Id, ISODateString, OctalNotationPermission } from "$/domain/entities/Ref"; +import { Struct } from "$/domain/entities/generic/Struct"; +import i18n from "$/utils/i18n"; + +export type DataSetAttrs = { + created: ISODateString; + id: Id; + description: string; + name: string; + lastUpdated: ISODateString; + dataPermissions: Permission; + metadataPermissions: Permission; + shortName: string; + coreCompetencies: CoreCompetency[]; + access: AccessData[]; + orgUnits: OrgUnit[]; +}; + +export type OrgUnit = { id: Id; name: string; paths: Id[] }; + +export type AccessData = { id: Id; value: OctalNotationPermission; name: string; type: AccessType }; +export type AccessType = "users" | "groups"; + +export type CoreCompetency = { id: Id; name: string; code: string }; + +export class DataSet extends Struct() { + static buildAccess(data: DataSet): string { + const dataDescription = DataSet.buildAccessDescription(data.dataPermissions); + const metadataDescription = DataSet.buildAccessDescription(data.metadataPermissions); + return `Data: ${dataDescription}, Metadata: ${metadataDescription}`; + } + + static convertPermissionToOctal(permission: Permission): OctalNotationPermission { + return permission.noAccess + ? "--" + : [permission.canRead ? "r" : "-", permission.canWrite ? "w" : "-"].join(""); + } + + static generateFullPermission(dataSet: DataSet): OctalNotationPermission { + return `${DataSet.convertPermissionToOctal( + dataSet.metadataPermissions + )}${DataSet.convertPermissionToOctal(dataSet.dataPermissions)}----`; + } + + static joinShortNames(dataSets: DataSet[], separator = ", "): string { + return dataSets.map(dataSet => dataSet.shortName).join(separator); + } + + private static buildAccessDescription(permission: Permission): string { + if (permission.noAccess) { + return i18n.t("No public access"); + } else if (permission.canWrite && permission.canRead) { + return i18n.t("Public view/edit"); + } else if (permission.canRead && !permission.canWrite) { + return i18n.t("Public view"); + } else { + return ""; + } + } +} diff --git a/src/domain/entities/Log.ts b/src/domain/entities/Log.ts new file mode 100644 index 00000000..49b65b59 --- /dev/null +++ b/src/domain/entities/Log.ts @@ -0,0 +1,34 @@ +import { DataSet } from "$/domain/entities/DataSet"; +import { ISODateString } from "$/domain/entities/Ref"; +import { User } from "$/domain/entities/User"; +import { Struct } from "$/domain/entities/generic/Struct"; +import _ from "$/domain/entities/generic/Collection"; +import { Maybe } from "$/utils/ts-utils"; + +export type LogsAttrs = { + date: ISODateString; + actionDescription: string; + action: "sharing" | "orgunits" | "delete" | "edit" | "create" | "clone" | "unknown"; + user: Pick; + status: LogStatus; + type: "dataSets"; + dataSets: Pick[]; +}; + +export type LogStatus = "success" | "failure"; + +export class Log extends Struct() { + static buildLogsWithDataSetDetails(dataSets: DataSet[], logs: Log[]): Log[] { + return logs.map(log => { + const logDataSets = _(log.dataSets) + .map((dataSet): Maybe => { + const dataSetDetails = dataSets.find(ds => ds.id === dataSet.id); + if (!dataSetDetails) return undefined; + return { id: dataSet.id, shortName: dataSetDetails?.name || "" }; + }) + .compact() + .value(); + return Log.create({ ...log, dataSets: logDataSets }); + }); + } +} diff --git a/src/domain/entities/Paginated.ts b/src/domain/entities/Paginated.ts new file mode 100644 index 00000000..053daf7f --- /dev/null +++ b/src/domain/entities/Paginated.ts @@ -0,0 +1,7 @@ +export type Paginated = { + page: number; + total: number; + pageSize: number; + pageCount: number; + data: T[]; +}; diff --git a/src/domain/entities/Permission.ts b/src/domain/entities/Permission.ts new file mode 100644 index 00000000..bfea8d49 --- /dev/null +++ b/src/domain/entities/Permission.ts @@ -0,0 +1,7 @@ +export type Permission = { + canRead: boolean; + canWrite: boolean; + noAccess: boolean; +}; + +export const NO_ACCESS_NOTATION = "--------"; diff --git a/src/domain/entities/Ref.ts b/src/domain/entities/Ref.ts index 8b95ca2f..9b0a9edc 100644 --- a/src/domain/entities/Ref.ts +++ b/src/domain/entities/Ref.ts @@ -1,4 +1,6 @@ export type Id = string; +export type ISODateString = string; +export type OctalNotationPermission = string; export interface Ref { id: Id; diff --git a/src/domain/entities/Sharing.ts b/src/domain/entities/Sharing.ts new file mode 100644 index 00000000..70715ac9 --- /dev/null +++ b/src/domain/entities/Sharing.ts @@ -0,0 +1,12 @@ +import { Id, OctalNotationPermission } from "$/domain/entities/Ref"; +import { Struct } from "$/domain/entities/generic/Struct"; + +export type SharingAttrs = { + publicAccess: OctalNotationPermission; + userAccesses: AccessDetails[]; + userGroupAccesses: AccessDetails[]; +}; + +export type AccessDetails = { id: Id; name: string }; + +export class Sharing extends Struct() {} diff --git a/src/domain/entities/generic/Future.ts b/src/domain/entities/generic/Future.ts index 0e95bd58..548ce1b1 100644 --- a/src/domain/entities/generic/Future.ts +++ b/src/domain/entities/generic/Future.ts @@ -134,6 +134,10 @@ export class Future { return Future.success(undefined); } + toVoid(): Future { + return this.map(() => undefined); + } + static block(blockFn: (capture: CaptureAsync) => Promise): Future { return new Future((): rcpromise.CancellablePromise => { return rcpromise.buildCancellablePromise(capturePromise => { diff --git a/src/domain/repositories/DataSetRepository.ts b/src/domain/repositories/DataSetRepository.ts new file mode 100644 index 00000000..bb904cb5 --- /dev/null +++ b/src/domain/repositories/DataSetRepository.ts @@ -0,0 +1,17 @@ +import { FutureData } from "$/data/api-futures"; +import { DataSet } from "$/domain/entities/DataSet"; +import { Paginated } from "$/domain/entities/Paginated"; +import { Id } from "$/domain/entities/Ref"; + +export interface DataSetRepository { + get(options: GetDataSetOptions): FutureData>; + getByIds(ids: Id[]): FutureData; + remove(ids: Id[]): FutureData; + save(dataSets: DataSet[]): FutureData; +} + +export type GetDataSetOptions = { + paging: { page: number; pageSize: number }; + sorting: { field: string; order: "asc" | "desc" }; + filters: { search?: string; ids?: Id[] }; +}; diff --git a/src/domain/repositories/LogRepository.ts b/src/domain/repositories/LogRepository.ts new file mode 100644 index 00000000..50f1f572 --- /dev/null +++ b/src/domain/repositories/LogRepository.ts @@ -0,0 +1,9 @@ +import { FutureData } from "$/data/api-futures"; +import { Log } from "$/domain/entities/Log"; +import { Id } from "$/domain/entities/Ref"; + +export interface LogRepository { + getByDataSets(options: GetLogsOptions): FutureData; +} + +export type GetLogsOptions = { dataSetsIds: Id[] }; diff --git a/src/domain/usecases/GetDataSetsByIdsUseCase.ts b/src/domain/usecases/GetDataSetsByIdsUseCase.ts new file mode 100644 index 00000000..34069b56 --- /dev/null +++ b/src/domain/usecases/GetDataSetsByIdsUseCase.ts @@ -0,0 +1,12 @@ +import { FutureData } from "$/data/api-futures"; +import { DataSet } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; + +export class GetDataSetsByIdsUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(ids: Id[]): FutureData { + return this.dataSetRepository.getByIds(ids); + } +} diff --git a/src/domain/usecases/GetDataSetsUseCase.ts b/src/domain/usecases/GetDataSetsUseCase.ts new file mode 100644 index 00000000..46107044 --- /dev/null +++ b/src/domain/usecases/GetDataSetsUseCase.ts @@ -0,0 +1,12 @@ +import { FutureData } from "$/data/api-futures"; +import { DataSet } from "$/domain/entities/DataSet"; +import { Paginated } from "$/domain/entities/Paginated"; +import { DataSetRepository, GetDataSetOptions } from "$/domain/repositories/DataSetRepository"; + +export class GetDataSetsUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(options: GetDataSetOptions): FutureData> { + return this.dataSetRepository.get(options); + } +} diff --git a/src/domain/usecases/GetLogsUseCase.ts b/src/domain/usecases/GetLogsUseCase.ts new file mode 100644 index 00000000..f7b30f1f --- /dev/null +++ b/src/domain/usecases/GetLogsUseCase.ts @@ -0,0 +1,20 @@ +import { FutureData } from "$/data/api-futures"; +import { Log } from "$/domain/entities/Log"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; +import { GetLogsOptions, LogRepository } from "$/domain/repositories/LogRepository"; + +export class GetLogsUseCase { + constructor( + private dataSetRepository: DataSetRepository, + private logsRepository: LogRepository + ) {} + + execute(options: GetLogsOptions): FutureData { + return this.dataSetRepository.getByIds(options.dataSetsIds).flatMap(dataSets => { + const ids = dataSets.map(dataSet => dataSet.id); + return this.logsRepository + .getByDataSets({ dataSetsIds: ids }) + .map(logs => Log.buildLogsWithDataSetDetails(dataSets, logs)); + }); + } +} diff --git a/src/domain/usecases/RemoveDataSetsUseCase.ts b/src/domain/usecases/RemoveDataSetsUseCase.ts new file mode 100644 index 00000000..416bccbf --- /dev/null +++ b/src/domain/usecases/RemoveDataSetsUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "$/data/api-futures"; +import { Id } from "$/domain/entities/Ref"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; + +export class RemoveDataSetsUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(ids: Id[]): FutureData { + return this.dataSetRepository.remove(ids); + } +} diff --git a/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts b/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts new file mode 100644 index 00000000..0e0e975d --- /dev/null +++ b/src/domain/usecases/SaveOrgUnitDataSetUseCase.ts @@ -0,0 +1,56 @@ +import { FutureData } from "$/data/api-futures"; +import { DataSet, OrgUnit } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; +import _ from "$/domain/entities/generic/Collection"; + +export class SaveOrgUnitDataSetUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(options: SaveOrgUnitsOptions): FutureData { + return this.getDataSetsByIds(options.dataSetsIds).flatMap(dataSets => { + const dataSetsToSave = + options.action === "replace" || options.dataSetsIds.length === 1 + ? this.replaceOrgUnits(dataSets, options.orgUnitsIds) + : this.mergeOrgUnits(dataSets, options.orgUnitsIds); + return this.dataSetRepository.save(dataSetsToSave); + }); + } + + private getDataSetsByIds(ids: string[]): FutureData { + return this.dataSetRepository.getByIds(ids); + } + + private replaceOrgUnits(dataSets: DataSet[], orgUnitsIds: Id[]): DataSet[] { + return dataSets.map(dataSet => { + return DataSet.create({ ...dataSet, ...this.buildOrgUnit(orgUnitsIds) }); + }); + } + + private mergeOrgUnits(dataSets: DataSet[], orgUnitsIds: Id[]): DataSet[] { + return dataSets.map(dataSet => { + return DataSet.create({ + ...dataSet, + orgUnits: this.mergeAndUniqueOrgUnits(dataSet, orgUnitsIds), + }); + }); + } + + private mergeAndUniqueOrgUnits(dataSet: DataSet, orgUnitsIds: Id[]): OrgUnit[] { + return _([...dataSet.orgUnits, ...this.buildOrgUnit(orgUnitsIds)]) + .uniqBy(orgUnit => orgUnit.id) + .value(); + } + + private buildOrgUnit(orgUnitsIds: Id[]): OrgUnit[] { + return orgUnitsIds.map(orgUnitId => { + return { id: orgUnitId, name: "", paths: [] }; + }); + } +} + +export type SaveOrgUnitsOptions = { + dataSetsIds: Id[]; + orgUnitsIds: Id[]; + action: "merge" | "replace"; +}; diff --git a/src/domain/usecases/SaveSharingDataSetsUseCase.ts b/src/domain/usecases/SaveSharingDataSetsUseCase.ts new file mode 100644 index 00000000..95ef0896 --- /dev/null +++ b/src/domain/usecases/SaveSharingDataSetsUseCase.ts @@ -0,0 +1,78 @@ +import { ShareUpdate } from "@eyeseetea/d2-ui-components"; + +import { FutureData } from "$/data/api-futures"; +import { AccessData, DataSet } from "$/domain/entities/DataSet"; +import { OctalNotationPermission } from "$/domain/entities/Ref"; +import { DataSetRepository } from "$/domain/repositories/DataSetRepository"; +import { NO_ACCESS_NOTATION, Permission } from "$/domain/entities/Permission"; + +export class SaveSharingDataSetsUseCase { + constructor(private dataSetRepository: DataSetRepository) {} + + execute(options: SaveDataSetOptions): FutureData { + const { publicAccess, userAccesses, userGroupAccesses } = options.shareUpdate; + const dataSetsIds = options.dataSets.map(dataSet => dataSet.id); + return this.getDataSetsByIds(dataSetsIds).flatMap(dataSets => { + const dataSetsWithPermissions = dataSets.map(dataSet => { + const users = userAccesses + ? userAccesses.map((user): AccessData => { + return { + id: user.id, + name: user.displayName, + type: "users", + value: user.access, + }; + }) + : dataSet.access.filter(access => access.type === "users"); + + const groups = userGroupAccesses + ? userGroupAccesses.map((user): AccessData => { + return { + id: user.id, + name: user.displayName, + type: "groups", + value: user.access, + }; + }) + : dataSet.access.filter(access => access.type === "groups"); + + return DataSet.create({ + ...dataSet, + access: [...users, ...groups], + dataPermissions: publicAccess + ? this.buildDataPermissions(publicAccess, "data") + : dataSet.dataPermissions, + metadataPermissions: publicAccess + ? this.buildDataPermissions(publicAccess, "metadata") + : dataSet.metadataPermissions, + }); + }); + + return this.dataSetRepository + .save(dataSetsWithPermissions) + .map(() => dataSetsWithPermissions); + }); + } + + private getDataSetsByIds(ids: string[]): FutureData { + return this.dataSetRepository.getByIds(ids); + } + + private buildDataPermissions( + value: OctalNotationPermission, + permissionType: "data" | "metadata" + ): Permission { + if (value === NO_ACCESS_NOTATION) { + return { canRead: false, canWrite: false, noAccess: true }; + } + const initialIndex = permissionType === "metadata" ? 0 : 2; + const canRead = value[initialIndex] === "r"; + const canWrite = value[initialIndex + 1] === "w"; + return { canRead, canWrite, noAccess: false }; + } +} + +export type SaveDataSetOptions = { + dataSets: DataSet[]; + shareUpdate: ShareUpdate; +}; diff --git a/src/domain/usecases/SearchSharingUseCase.ts b/src/domain/usecases/SearchSharingUseCase.ts new file mode 100644 index 00000000..5a117807 --- /dev/null +++ b/src/domain/usecases/SearchSharingUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "$/data/api-futures"; +import { SharingRepository } from "$/data/repositories/SharingRepository"; +import { Sharing } from "$/domain/entities/Sharing"; + +export class SearchSharingUseCase { + constructor(private sharingRepository: SharingRepository) {} + + execute(search: string): FutureData { + return this.sharingRepository.getBy(search); + } +} diff --git a/src/types/d2-api.ts b/src/types/d2-api.ts index b8db7a3a..24a5e0d3 100644 --- a/src/types/d2-api.ts +++ b/src/types/d2-api.ts @@ -5,3 +5,28 @@ export { CancelableResponse } from "@eyeseetea/d2-api"; export { D2Api } from "@eyeseetea/d2-api/2.36"; export type { MetadataPick } from "@eyeseetea/d2-api/2.36"; export const getMockApi = getMockApiFromClass(D2Api); + +interface LocalInstance { + type: "local"; + url: string; +} + +interface ExternalInstance { + type: "external"; + url: string; + username: string; + password: string; +} + +export type DhisInstance = LocalInstance | ExternalInstance; + +export function getD2APiFromInstance(instance: DhisInstance) { + return new D2Api({ + baseUrl: instance.url, + auth: + instance.type === "external" + ? { username: instance.username, password: instance.password } + : undefined, + backend: "fetch", + }); +} diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index f1f82e38..c8f55063 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -4,11 +4,13 @@ import { ReactNode } from "react"; import { AppContext, AppContextState } from "$/webapp/contexts/app-context"; import { getTestCompositionRoot } from "$/CompositionRoot"; import { createAdminUser } from "$/domain/entities/__tests__/userFixtures"; +import { D2Api } from "$/types/d2-api"; export function getTestContext() { const context: AppContextState = { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), + api: {} as D2Api, }; return context; diff --git a/src/webapp/components/confirmation-modal/ConfirmationModal.tsx b/src/webapp/components/confirmation-modal/ConfirmationModal.tsx new file mode 100644 index 00000000..3e96093f --- /dev/null +++ b/src/webapp/components/confirmation-modal/ConfirmationModal.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import i18n from "$/utils/i18n"; + +export type ConfirmationModalProps = { + visible: boolean; + onSave: () => void; + onCancel: () => void; +}; + +export const ConfirmationModal = React.memo((props: ConfirmationModalProps) => { + const { visible, onSave, onCancel } = props; + return ( + + ); +}); diff --git a/src/webapp/components/dataset-logs/DataSetLogs.tsx b/src/webapp/components/dataset-logs/DataSetLogs.tsx new file mode 100644 index 00000000..beaa36d1 --- /dev/null +++ b/src/webapp/components/dataset-logs/DataSetLogs.tsx @@ -0,0 +1,89 @@ +import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import React from "react"; +import styled from "styled-components"; + +import { Id } from "$/domain/entities/Ref"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { useGetDataSetsByIds } from "$/webapp/hooks/useDataSets"; +import { Log } from "$/domain/entities/Log"; +import { DataSet } from "$/domain/entities/DataSet"; + +export type DataSetLogsProps = { dataSetIds: Id[]; onCancel: () => void }; + +export const DataSetLogs = React.memo((props: DataSetLogsProps) => { + const { dataSetIds, onCancel } = props; + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const loading = useLoading(); + const [logs, setLogs] = React.useState(); + + const { dataSets } = useGetDataSetsByIds(dataSetIds); + + React.useEffect(() => { + loading.show(true, i18n.t("Loading logs...")); + const dataSetsIds = dataSets?.map(dataSet => dataSet.id) || []; + return compositionRoot.logs.getByDataSets.execute({ dataSetsIds }).run( + logs => { + setLogs(logs); + loading.hide(); + }, + err => { + loading.hide(); + snackbar.error(err.message); + } + ); + }, [compositionRoot.logs.getByDataSets, dataSets, loading, snackbar]); + + return ( + + {logs?.map(log => { + return ( + + + + + + ds.shortName).join(", ")} + /> + + ); + })} + + ); +}); + +export type LogItemProps = { label: string; value: string }; + +export const LogItem = React.memo((props: LogItemProps) => { + const { label, value } = props; + return ( + + {label}: + {value} + + ); +}); + +const LogsContainer = styled.ul` + color: rgba(0, 0, 0, 0.6); + list-style: none; + padding-block: 0.5em; + padding-inline: 0; + margin: 0; +`; + +const LogItemContainer = styled.li` + line-height: 1; +`; + +DataSetLogs.displayName = "DataSetLogs"; diff --git a/src/webapp/components/dataset-table/DataSetTable.tsx b/src/webapp/components/dataset-table/DataSetTable.tsx new file mode 100644 index 00000000..4ed88624 --- /dev/null +++ b/src/webapp/components/dataset-table/DataSetTable.tsx @@ -0,0 +1,262 @@ +import { DataSet, DataSetAttrs } from "$/domain/entities/DataSet"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { + ObjectsTable, + useLoading, + useObjectsTable, + useSnackbar, +} from "@eyeseetea/d2-ui-components"; +import React from "react"; +import SharingIcon from "@material-ui/icons/Share"; +import EditIcon from "@material-ui/icons/Edit"; +import DomainIcon from "@material-ui/icons/Domain"; +import DateRangeIcon from "@material-ui/icons/DateRange"; +import DetailsIcon from "@material-ui/icons/Details"; +import DeleteIcon from "@material-ui/icons/Delete"; +import CopyIcon from "@material-ui/icons/FileCopy"; +import ListIcon from "@material-ui/icons/List"; +import _ from "$/domain/entities/generic/Collection"; +import { SharingDetails } from "$/webapp/components/sharing-details/SharingDetails"; +import { Id } from "$/domain/entities/Ref"; +import { ConfirmationModal } from "$/webapp/components/confirmation-modal/ConfirmationModal"; +import { EditSharing } from "$/webapp/components/edit-sharing/EditSharing"; +import { EditOrgUnits } from "$/webapp/components/edit-orgunits/EditOrgUnits"; +import { DataSetLogs } from "$/webapp/components/dataset-logs/DataSetLogs"; + +export type DataSetColumns = DataSetAttrs & { permissionDescription: string }; +export type TableAction = { + ids: Id[]; + action: "remove" | "sharing" | "orgUnits" | "logs"; +}; + +function checkIdsAndAction( + tableAction: TableAction | undefined, + actionToValidate: TableAction["action"] +): boolean { + if (!tableAction) return false; + return tableAction.ids.length > 0 && tableAction.action === actionToValidate; +} + +export const DataSetTable: React.FC = React.memo(() => { + const { compositionRoot } = useAppContext(); + const [refreshTable, setRefreshTable] = React.useState(0); + const [tableAction, setTableAction] = React.useState(); + const snackbar = useSnackbar(); + const loading = useLoading(); + const tableConfig = useObjectsTable( + React.useMemo(() => { + return { + columns: [ + { + name: "id", + text: i18n.t("Id"), + hidden: true, + }, + { + name: "name", + text: i18n.t("Name"), + sortable: true, + getValue: dataSet => dataSet.name, + }, + { + name: "permissionDescription", + text: i18n.t("Access"), + sortable: false, + getValue: dataSet => dataSet.permissionDescription, + }, + { + name: "lastUpdated", + text: i18n.t("Last updated"), + getValue: dataSet => dataSet.lastUpdated, + }, + ], + details: [ + { name: "name", text: i18n.t("Name") }, + { name: "shortName", text: i18n.t("Short Name") }, + { name: "description", text: i18n.t("Description") }, + { name: "created", text: i18n.t("Created") }, + { name: "lastUpdated", text: i18n.t("Last updated") }, + { name: "id", text: i18n.t("ID") }, + { + name: "coreCompetencies", + text: i18n.t("Core competencies"), + getValue: value => { + return value.coreCompetencies.map(cc => cc.name).join(", "); + }, + }, + { + name: "access", + text: i18n.t("Sharing"), + getValue: dataSet => { + return ; + }, + }, + ], + actions: [ + { + name: "edit", + text: i18n.t("Edit"), + icon: , + multiple: false, + }, + { + name: "sharing", + text: i18n.t("Sharing Settings"), + icon: , + multiple: true, + onClick(selectedIds) { + setTableAction({ ids: selectedIds, action: "sharing" }); + }, + }, + { + name: "assign_orgunits", + text: i18n.t("Assign to Organisation Units"), + icon: , + multiple: true, + onClick: selectedIds => { + setTableAction({ ids: selectedIds, action: "orgUnits" }); + }, + }, + { + name: "set_period_dates", + text: i18n.t("Set output/outcome period dates"), + icon: , + multiple: true, + }, + { + name: "set_end_dates", + text: i18n.t("Change output/outcome end date for year"), + icon: , + multiple: true, + }, + { + name: "details", + text: i18n.t("Details"), + icon: , + multiple: false, + }, + { + name: "clone", + text: i18n.t("Clone"), + icon: , + multiple: false, + }, + { + name: "delete", + text: i18n.t("Delete"), + icon: , + multiple: true, + onClick(selectedIds) { + setTableAction({ ids: selectedIds, action: "remove" }); + }, + }, + { + name: "logs", + text: i18n.t("Logs"), + icon: , + multiple: true, + onClick(selectedIds) { + setTableAction({ ids: selectedIds, action: "logs" }); + }, + }, + ], + initialSorting: { field: "name", order: "asc" }, + paginationOptions: { pageSizeInitialValue: 50, pageSizeOptions: [50, 100, 200] }, + searchBoxLabel: i18n.t("Search"), + }; + }, [setTableAction]), + React.useCallback( + (search, pagination, sorting) => { + console.debug(refreshTable); + return new Promise((resolve, reject) => { + return compositionRoot.dataSets.getAll + .execute({ paging: pagination, sorting: sorting, filters: { search } }) + .run( + response => { + resolve({ + objects: response.data.map(dataSet => { + return { + ...dataSet, + permissionDescription: DataSet.buildAccess(dataSet), + }; + }), + pager: { + page: response.page, + pageCount: response.pageCount, + total: response.total, + pageSize: response.pageSize, + }, + }); + }, + err => { + reject(new Error(err.message)); + } + ); + }); + }, + [compositionRoot.dataSets.getAll, refreshTable] + ) + ); + + const closeModal = React.useCallback(() => { + setTableAction(undefined); + }, []); + + const clearTableAction = React.useCallback((refreshTable?: boolean) => { + setTableAction(undefined); + if (refreshTable) { + setRefreshTable(prevValue => prevValue + 1); + } + }, []); + + const removeDataSets = React.useCallback(() => { + loading.show(true, i18n.t("Removing DataSets")); + return compositionRoot.dataSets.remove.execute(tableAction?.ids || []).run( + () => { + clearTableAction(); + setRefreshTable(prevValue => prevValue + 1); + snackbar.success(i18n.t("DataSets removed")); + loading.hide(); + }, + err => { + snackbar.error(err.message); + loading.hide(); + clearTableAction(); + } + ); + }, [ + clearTableAction, + compositionRoot.dataSets.remove, + loading, + snackbar, + tableAction, + setRefreshTable, + ]); + + return ( +
+ + {checkIdsAndAction(tableAction, "remove") && ( + + )} + {checkIdsAndAction(tableAction, "sharing") && ( + clearTableAction(true)} + dataSetIds={tableAction?.ids || []} + /> + )} + {checkIdsAndAction(tableAction, "orgUnits") && ( + clearTableAction(true)} + dataSetIds={tableAction?.ids || []} + /> + )} + {checkIdsAndAction(tableAction, "logs") && ( + + )} +
+ ); +}); + +DataSetTable.displayName = "DataSetTable"; diff --git a/src/webapp/components/edit-orgunits/EditOrgUnits.tsx b/src/webapp/components/edit-orgunits/EditOrgUnits.tsx new file mode 100644 index 00000000..93130328 --- /dev/null +++ b/src/webapp/components/edit-orgunits/EditOrgUnits.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { ConfirmationDialog, OrgUnitsSelector } from "@eyeseetea/d2-ui-components"; +import { Id } from "$/domain/entities/Ref"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { useGetDataSetsByIds, useSaveOrgUnits } from "$/webapp/hooks/useDataSets"; +import _ from "$/domain/entities/generic/Collection"; +import { FormControlLabel, Switch } from "@material-ui/core"; + +export type EditOrgUnitsProps = { dataSetIds: Id[]; onCancel: () => void }; + +export const EditOrgUnits = React.memo((props: EditOrgUnitsProps) => { + const { dataSetIds, onCancel } = props; + const { api } = useAppContext(); + + const { dataSets } = useGetDataSetsByIds(dataSetIds); + const firstDataSet = _(dataSets || []).first(); + const multipleDataSets = dataSetIds.length > 1; + const [selectedOrgUnits, setSelectedOrgUnits] = React.useState(); + const [action, setAction] = React.useState<"merge" | "replace">("replace"); + + const { saveOrgUnits } = useSaveOrgUnits(); + + const onUpdateOrgUnit = React.useCallback((paths: string[]) => { + setSelectedOrgUnits(paths); + }, []); + + const initialOrgUnits = selectedOrgUnits + ? selectedOrgUnits + : multipleDataSets + ? [] + : firstDataSet?.orgUnits.map(ou => `/${ou.paths.join("/")}`) || []; + + const onSaveOrgUnits = React.useCallback(() => { + if (!selectedOrgUnits) return; + return saveOrgUnits(dataSetIds, selectedOrgUnits, action); + }, [action, dataSetIds, saveOrgUnits, selectedOrgUnits]); + + const actionLabel = action === "replace" ? i18n.t("Replace") : i18n.t("Merge"); + + const updateAction = React.useCallback( + (_: React.ChangeEvent, value: boolean) => { + setAction(value ? "replace" : "merge"); + }, + [] + ); + + if (!firstDataSet) return null; + + return ( + + {multipleDataSets && ( + + } + label={i18n.t("Bulk update strategy: {{actionLabel}}", { + nsSeparator: false, + actionLabel, + })} + /> + )} + + + + ); +}); + +EditOrgUnits.displayName = "EditOrgUnits"; diff --git a/src/webapp/components/edit-sharing/EditSharing.tsx b/src/webapp/components/edit-sharing/EditSharing.tsx new file mode 100644 index 00000000..73ea66a9 --- /dev/null +++ b/src/webapp/components/edit-sharing/EditSharing.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { + ConfirmationDialog, + SearchResult, + ShareUpdate, + Sharing as SharingModal, + SharingRule, + useLoading, + useSnackbar, +} from "@eyeseetea/d2-ui-components"; +import { Id } from "$/domain/entities/Ref"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import _ from "$/domain/entities/generic/Collection"; +import { AccessType, DataSet } from "$/domain/entities/DataSet"; +import { Maybe } from "$/utils/ts-utils"; +import { useGetDataSetsByIds } from "$/webapp/hooks/useDataSets"; + +export type EditSharingProps = { dataSetIds: Id[]; onCancel: () => void }; + +const sharingOptions = { + dataSharing: true, + publicSharing: true, + externalSharing: true, + permissionPicker: true, +}; + +export const EditSharing = React.memo((props: EditSharingProps) => { + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const loading = useLoading(); + const { dataSetIds, onCancel } = props; + const [sharingValue, setSharingValue] = React.useState(); + const { dataSets, setDataSets } = useGetDataSetsByIds(dataSetIds); + + const multipleDataSets = dataSetIds.length > 1; + + const dataSet = _(dataSets || []).first(); + const joinDataSetsShortNames = dataSets?.map(ds => ds.shortName).join(", "); + + const saveDataSets = React.useCallback( + async (shareUpdate: ShareUpdate) => { + if (!dataSets) return Promise.resolve(); + loading.show(true, i18n.t("Saving sharing settings...")); + return compositionRoot.dataSets.save + .execute({ dataSets: dataSets, shareUpdate: shareUpdate }) + .toPromise() + .then(dataSets => { + setSharingValue(shareUpdate); + setDataSets(dataSets); + snackbar.success(i18n.t("Sharing settings saved")); + loading.hide(); + }) + .catch(error => { + snackbar.error(error.message); + loading.hide(); + }); + }, + [compositionRoot.dataSets.save, dataSets, loading, snackbar, setDataSets, setSharingValue] + ); + + const searchSharing = React.useCallback( + async (value: string) => { + return compositionRoot.sharing.search + .execute(value) + .toPromise() + .then((response): SearchResult => { + return { + userGroups: response.userGroupAccesses.map(group => { + return { displayName: group.name, id: group.id }; + }), + users: response.userAccesses.map(user => { + return { displayName: user.name, id: user.id }; + }), + }; + }); + }, + [compositionRoot.sharing.search] + ); + + const sharingMetaValue = { + object: { + id: dataSet?.id || "", + displayName: multipleDataSets + ? `[${dataSetIds.length}] ${joinDataSetsShortNames}` + : dataSet?.name, + externalAccess: false, + publicAccess: + dataSet && !sharingValue?.publicAccess + ? DataSet.generateFullPermission(dataSet) + : sharingValue?.publicAccess, + userAccesses: buildAccessByType(multipleDataSets, dataSet, "users", sharingValue), + userGroupAccesses: buildAccessByType(multipleDataSets, dataSet, "groups", sharingValue), + }, + meta: { allowExternalAccess: false, allowPublicAccess: true }, + }; + + return ( + + + + ); +}); + +function buildAccessByType( + multipleDataSets: boolean, + dataSet: Maybe, + type: AccessType, + sharingValue: Maybe +): Maybe { + const selectedValues = + type === "users" ? sharingValue?.userAccesses : sharingValue?.userGroupAccesses; + + const dataSetAccess = _(dataSet?.access || []) + .filter(access => access.type === type) + .map((access): SharingRule => { + return { access: access.value, displayName: access.name, id: access.id }; + }) + .value(); + + return multipleDataSets + ? selectedValues + : _([...dataSetAccess, ...(selectedValues || [])]) + .uniqBy(access => access.id) + .value(); +} + +EditSharing.displayName = "EditSharing"; diff --git a/src/webapp/components/sharing-details/SharingDetails.tsx b/src/webapp/components/sharing-details/SharingDetails.tsx new file mode 100644 index 00000000..1eec83ed --- /dev/null +++ b/src/webapp/components/sharing-details/SharingDetails.tsx @@ -0,0 +1,53 @@ +import i18n from "$/utils/i18n"; +import styled from "styled-components"; +import React from "react"; +import _ from "$/domain/entities/generic/Collection"; +import { DataSetColumns } from "$/webapp/components/dataset-table/DataSetTable"; + +export type SharingDetailsProps = { + dataSet: DataSetColumns; +}; + +export const SharingDetails = React.memo((props: SharingDetailsProps) => { + const { dataSet } = props; + const accessByType = _(dataSet.access).groupBy(access => access.type); + + return ( +
+ + {accessByType + .mapValues(([type, accessData]) => { + const accessTypeLabel = + type === "users" ? i18n.t("User access") : i18n.t("Groups"); + return ( + access.name).join(", ")} + /> + ); + }) + .values()} +
+ ); +}); + +export const PermissionItem = React.memo((props: { label: string; description: string }) => { + const { label, description } = props; + return ( + + {label}: + {description} + + ); +}); + +const StyledSharingDetails = styled.p` + color: #4d4b4b; + font-style: italic; +`; + +SharingDetails.displayName = "SharingDetails"; diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 3e044f54..6bbdd27e 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -1,10 +1,12 @@ import React, { useContext } from "react"; import { CompositionRoot } from "$/CompositionRoot"; import { User } from "$/domain/entities/User"; +import { D2Api } from "$/types/d2-api"; export interface AppContextState { currentUser: User; compositionRoot: CompositionRoot; + api: D2Api; } export const AppContext = React.createContext(null); diff --git a/src/webapp/hooks/useDataSets.ts b/src/webapp/hooks/useDataSets.ts new file mode 100644 index 00000000..8f8cb143 --- /dev/null +++ b/src/webapp/hooks/useDataSets.ts @@ -0,0 +1,64 @@ +import { DataSet } from "$/domain/entities/DataSet"; +import { Id } from "$/domain/entities/Ref"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import React from "react"; + +export function useGetDataSetsByIds(ids: Id[]) { + const { compositionRoot } = useAppContext(); + const loading = useLoading(); + const snackbar = useSnackbar(); + const [dataSets, setDataSets] = React.useState(); + + React.useEffect(() => { + loading.show(true, "Loading data sets"); + + return compositionRoot.dataSets.getByIds.execute(ids).run( + dataSets => { + setDataSets(dataSets); + loading.hide(); + }, + error => { + snackbar.error(error.message); + loading.hide(); + } + ); + }, [compositionRoot.dataSets.getByIds, ids, loading, snackbar]); + + return { dataSets, setDataSets }; +} + +export function useSaveOrgUnits() { + const { compositionRoot } = useAppContext(); + const loading = useLoading(); + const snackbar = useSnackbar(); + + const saveOrgUnits = React.useCallback( + (dataSetsIds: Id[], orgUnitsIds: Id[], action: "merge" | "replace") => { + loading.show(true, "Saving organisation units"); + + const ids = orgUnitsIds.map(path => { + const parts = path.split("/"); + const lastPart = parts.at(-1); + if (!lastPart) throw new Error(`Cannot get orgunit: ${lastPart}`); + return lastPart; + }); + + return compositionRoot.dataSets.saveOrgUnits + .execute({ action, dataSetsIds, orgUnitsIds: ids }) + .run( + () => { + snackbar.success("Organisation units saved"); + loading.hide(); + }, + error => { + snackbar.error(error.message); + loading.hide(); + } + ); + }, + [compositionRoot.dataSets.saveOrgUnits, loading, snackbar] + ); + + return { saveOrgUnits }; +} diff --git a/src/webapp/pages/Router.tsx b/src/webapp/pages/Router.tsx index e011f989..b6e0e013 100644 --- a/src/webapp/pages/Router.tsx +++ b/src/webapp/pages/Router.tsx @@ -1,18 +1,10 @@ -import React from "react"; import { HashRouter, Route, Switch } from "react-router-dom"; -import { ExamplePage } from "./example/ExamplePage"; import { LandingPage } from "./landing/LandingPage"; export function Router() { return ( - } - /> - - {/* Default route */} } /> diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 28177875..4d5483e2 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -1,6 +1,6 @@ import styled from "styled-components"; import { HeaderBar } from "@dhis2/ui"; -import { SnackbarProvider } from "@eyeseetea/d2-ui-components"; +import { LoadingProvider, SnackbarProvider } from "@eyeseetea/d2-ui-components"; import { MuiThemeProvider } from "@material-ui/core/styles"; //@ts-ignore import OldMuiThemeProvider from "material-ui/styles/MuiThemeProvider"; @@ -11,13 +11,15 @@ import { Router } from "$/webapp/pages/Router"; import "./App.css"; import muiThemeLegacy from "./themes/dhis2-legacy.theme"; import { muiTheme } from "./themes/dhis2.theme"; +import { D2Api } from "$/types/d2-api"; export interface AppProps { compositionRoot: CompositionRoot; + api: D2Api; } function App(props: AppProps) { - const { compositionRoot } = props; + const { api, compositionRoot } = props; const [loading, setLoading] = useState(true); const [appContext, setAppContext] = useState(null); @@ -26,26 +28,29 @@ function App(props: AppProps) { const currentUser = await compositionRoot.users.getCurrent.execute().toPromise(); if (!currentUser) throw new Error("User not logged in"); - setAppContext({ currentUser, compositionRoot }); + setAppContext({ api, currentUser, compositionRoot }); setLoading(false); } setup(); - }, [compositionRoot]); + }, [compositionRoot, api]); if (loading) return null; return ( - - - -
- - - -
-
+ {/* @ts-ignore */} + + + + +
+ + + +
+
+
); diff --git a/src/webapp/pages/app/Dhis2App.tsx b/src/webapp/pages/app/Dhis2App.tsx index e1c0dc50..b0f1b79b 100644 --- a/src/webapp/pages/app/Dhis2App.tsx +++ b/src/webapp/pages/app/Dhis2App.tsx @@ -29,12 +29,12 @@ export function Dhis2App(_props: {}) { ); } case "loaded": { - const { baseUrl, compositionRoot } = compositionRootRes.data; + const { api, baseUrl, compositionRoot } = compositionRootRes.data; const config = { baseUrl, apiVersion: 30 }; return ( - + ); } @@ -44,6 +44,7 @@ export function Dhis2App(_props: {}) { type Data = { compositionRoot: CompositionRoot; baseUrl: string; + api: D2Api; }; async function getData(): Promise { @@ -60,7 +61,7 @@ async function getData(): Promise { configI18n(userSettings); try { - return { type: "loaded", data: { baseUrl, compositionRoot } }; + return { type: "loaded", data: { baseUrl, compositionRoot, api } }; } catch (err) { return { type: "error", error: { baseUrl, error: err as Error } }; } diff --git a/src/webapp/pages/app/__tests__/App.spec.tsx b/src/webapp/pages/app/__tests__/App.spec.tsx index 8bdd000e..453f4a34 100644 --- a/src/webapp/pages/app/__tests__/App.spec.tsx +++ b/src/webapp/pages/app/__tests__/App.spec.tsx @@ -1,25 +1,25 @@ -import { fireEvent, render } from "@testing-library/react"; +import { render } from "@testing-library/react"; import App from "$/webapp/pages/app/App"; import { getTestContext } from "$/utils/tests"; import { Provider } from "@dhis2/app-runtime"; +import { getD2APiFromInstance } from "$/types/d2-api"; describe("App", () => { it("navigates to page", async () => { const view = getView(); - fireEvent.click(await view.findByText("John")); - - expect(await view.findByText("Hello John")).toBeInTheDocument(); - expect(view.asFragment()).toMatchSnapshot(); + expect(await view.findByText("No results found")).toBeInTheDocument(); }); }); function getView() { const { compositionRoot } = getTestContext(); + const baseUrl = "http://localhost:8080"; + const api = getD2APiFromInstance({ type: "local", url: baseUrl }); return render( - + ); } diff --git a/src/webapp/pages/app/__tests__/__snapshots__/App.spec.tsx.snap b/src/webapp/pages/app/__tests__/__snapshots__/App.spec.tsx.snap deleted file mode 100644 index 077af084..00000000 --- a/src/webapp/pages/app/__tests__/__snapshots__/App.spec.tsx.snap +++ /dev/null @@ -1,48 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`App > navigates to page 1`] = ` - -
-
-
- -
- Detail page -
-
-

- Hello John -

-
- -`; diff --git a/src/webapp/pages/example/__tests__/ExamplePage.spec.tsx b/src/webapp/pages/example/__tests__/ExamplePage.spec.tsx deleted file mode 100644 index b4ea226c..00000000 --- a/src/webapp/pages/example/__tests__/ExamplePage.spec.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { getReactComponent } from "$/utils/tests"; -import { ExamplePage } from "$/webapp/pages/example/ExamplePage"; - -describe("ExamplePage", () => { - it("renders the feedback component", async () => { - const view = getView(); - - expect(await view.findByText("Hello Mary")).toBeInTheDocument(); - expect(view.asFragment()).toMatchSnapshot(); - }); -}); - -function getView() { - return getReactComponent(); -} diff --git a/src/webapp/pages/example/__tests__/__snapshots__/ExamplePage.spec.tsx.snap b/src/webapp/pages/example/__tests__/__snapshots__/ExamplePage.spec.tsx.snap deleted file mode 100644 index b1962f72..00000000 --- a/src/webapp/pages/example/__tests__/__snapshots__/ExamplePage.spec.tsx.snap +++ /dev/null @@ -1,40 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ExamplePage > renders the feedback component 1`] = ` - -
- -
- Detail page -
-
-

- Hello Mary -

-
-`; diff --git a/src/webapp/pages/landing/LandingPage.tsx b/src/webapp/pages/landing/LandingPage.tsx index 2166c73d..96b416a0 100644 --- a/src/webapp/pages/landing/LandingPage.tsx +++ b/src/webapp/pages/landing/LandingPage.tsx @@ -1,40 +1,10 @@ -import { Typography } from "@material-ui/core"; import React from "react"; -import { useHistory } from "react-router-dom"; -import { Card, CardGrid } from "$/webapp/components/card-grid/CardGrid"; -import { useAppContext } from "$/webapp/contexts/app-context"; -import i18n from "$/utils/i18n"; +import { DataSetTable } from "$/webapp/components/dataset-table/DataSetTable"; export const LandingPage: React.FC = React.memo(() => { - const history = useHistory(); - const { currentUser } = useAppContext(); - - const cards: Card[] = [ - { - title: i18n.t("Section"), - key: "main", - children: [ - { - name: "John", - description: "Entry point 1", - listAction: () => history.push("/for/John"), - }, - { - name: "Mary", - description: "Entry point 2", - listAction: () => history.push("/for/Mary"), - }, - ], - }, - ]; - return ( <> - - Current user: {currentUser.name} [{currentUser.id}] - - - + ); });