1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[EC-16] Implement new Groups Tab (#3563)

* [EC-16] Cleanup RxJS linting problems

* [EC-16] Update Group tab to use table component and show collections.

* [EC-16] Extract interface from GroupResponse and use it in the view

* [EC-16] Remove heading underline

* [EC-16] Cleanup i18n

* [EC-16] More i18n cleanup

* [EC-16] Fix bulk group request type name

* [EC-16] Rename group details type

* [EC-86] Clear collectionMap before populating it with new collections

* [EC-86] Update initialization/loading logic to make better use of the Observable pattern

* [EC-86] Make table cells use a pointer cursor

* [EC-86] Use bitIconButton for row menu triggers

* [EC-86] Refactor GroupDetailsRow interface to wrap GroupDetailsResponse.

 Remove response model interfaces.

 Cleanup GroupsComponent.

* [EC-86] Add bit-badge-list component and tweak BadgeModule to support both the component and directive.

Update mockI18nService to support templated strings.

* [EC-86] Cleanup badge color and bitIconButton classes

* [EC-86] Cleanup more styles

* [EC-86] Add GroupApiService

Add a new GroupApiService to replace Group Api calls in the ApiService.

* [EC-86] Revisions for badge-list implementation.

- Remove `| null` for maxItems according to ADR-0014
- Remove custom setter for items
- Use ngOnChanges to update filteredItems
- Fix sr-only tailwind class and show screen reader comma after last item if truncated.

* [EC-86] Refactor badge-list module/component

- Move the badge list component to its own module.
- Extract badge list stories from badge stories.
- Cleanup bade stories and module after refactor.

* [EC-86] Refactor/rename GroupApiService

- Re-name GroupApiService to GroupService
 as there is no need for a separate Api service (no sync or local data for admin services)
- Add GroupView for use in the GroupService instead of raw API models
- Update views to use GroupView instead of raw GroupResponse models

* [EC-86] Refactor group API request models

- Move organizationGroupBulkRequest to group requests folder
- Fix relative imports in GroupService

* [EC-86] Fix linting errors

* Fix tab item text color

Tab item text color broke after a merge from master and needs a fix to account for bootstrap styles in Web.

* [EC-86] Rename new files using kebab-case

* [EC-86] Fix group view file name

* [EC-86] Fix group request/response file names

* [EC-86] Cleanup badge stories per review suggestions

* [EC-86] Use inline-flex for badge list container

* [EC-86] Move GroupService and Views to Web org module

- Move GroupService and GroupServiceAbstraction to Organization Module
- Add GroupService provider to Organization Module
- Move collection-add-edit.component, user-groups.component, group-add-edit.component, and groups.component into Organization Module as they now depend on GroupService
- Remove moved components from Loose Component module

* [EC-86] Fix Group table search

Adds the id and name properties to GroupDetailsRow to support using the searchPipe (which cannot access nested values such as details.name for filtering).

* [EC-86] Fix badge story controls

* [EC-87] Edit Group Dialog (#3651)

* [EC-87] Update the edit dialog to use content tabs

* [EC-87] WIP FormListSelection abstract controller

* [EC-87] WIP FormListSelection for members and collections

* [EC-87] More WIP on FormListSelection

* [EC-87] WIP Working FormSelectionList with initial value support

* [EC-87] WIP SelectionList without FormControls and with i18n support for sorting

* [EC-87] Final sorted SelectionList with FormArray support

* [EC-87] Extract and document FormSelectionList

* [EC-87] Functional edit group modal

* [EC-87] Remove button icon padding for bitButton directives

* [EC-87] Use new disablePadding attribute for Dialog component

* [EC-87] Some more cleanup and finetuning

* [EC-87] Move enum declaration to top

* [EC-87] Remove inline style from access selector

* [EC-87] Move Group components into Organization Module

* [EC-87] Add MultiSelectModule to Shared Web module

* [EC-87] Integrate AccessSelector component in GroupAddEdit modal

- Remove duplicate permission / selection readonly helpers from GroupAddEdit component
- Use access item views/values for collection and member lists
- Replace access selector HTMl with the AccessSelector component

* [EC-87] Update Group collections column to open Collection tab

* [EC-87] Remove old FormSelectionList file

* [EC-87] Fix missed file import changes after merge

* [EC-87] Remove GroupAddEditComponent modal service registration

Groups component is now using the DialogService which does not require explicit registration for lazy loaded components.

* [EC-87] Use injected DIALOG_DATA for GroupAddEdit component

- Add types for the GroupAddEdit dialog params, result, and tab indices
- Add strongly typed helper method to open GroupAddEdit dialogs
- Remove @Input()/@Output() properties. Replaced with the injected DIALOG_DATA params instead
- Use dialogRef.close() and result type instead of event emitters

* [EC-87] Rename collection tab type to collections

* [EC-87] Refactor postGroup() and putGroup() from ApiService

- Move postGroup() and putGroup() methods to GroupService
- Remove postGroup() and putGroup() from ApiService
- Move GroupResponse and GroupRequest into Web (from lib/common)

* [EC-87] Remove required attribute

* [EC-87] Use PascalCase for template Enums

* [EC-87] Use group modal tab enum in template

* [EC-87] Convert dialog result to promise

* [EC-87] Refactor dialog positionStrategy

- Add .top() to position strategy to allow clicking the backdrop to close the dialog
- Move the positionStrategy option into the openGroupAddEditDialog helper

* [EC-87] Remove [preserveContent] from tab group

* [EC-87] Use new CL async actions

- Update handlers to be arrow-functions
- Remove old form and delete promises
- Use [bitSubmit] directive on form
- Use bitFormButton directive and [bitAction] for submit and delete buttons
- Remove delete/spinner bwi icons as they are handled by the new async directives

* [EC-87] Introduce CollectionAccessSelectionView

Use a new view to replace the SelectionReadonlyResponse/Request classes.

* [EC-87] Use new access selection view in GroupView

- Change the collections type
- Add members list to make the view more complete
- Update the static fromResponse helper to properly map the GroupDetailsResponse to the new access selection view
- Update access selector helpers to use new access selection view instead of response/request models

* [EC-87] Update GroupService to have a single save() method that accepts a GroupView

- Add save() method that checks for existing group id to determine which API method to use
- Make post/put group methods private

* [EC-87] Utilize the new save() method in the group modal

* [EC-87] Use observables for fetching data

- Introduce 3 observables for collections, members, and group details
- Combine and subscribe to those observables in ngOnInit
- Add destroy$ subject
- Inject changeDetectorRef to handle quirk of patching the AccessSelector value before available items are set
This commit is contained in:
Shane Melton
2022-11-21 08:40:27 -08:00
committed by GitHub
parent 1dd9fbab72
commit 21a9f84956
37 changed files with 1129 additions and 511 deletions

View File

@@ -29,6 +29,7 @@ import { I18nPipe } from "./pipes/i18n.pipe";
import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe";
import { SearchPipe } from "./pipes/search.pipe";
import { UserNamePipe } from "./pipes/user-name.pipe";
import { UserTypePipe } from "./pipes/user-type.pipe";
import { PasswordStrengthComponent } from "./shared/components/password-strength/password-strength.component";
@NgModule({
@@ -70,6 +71,7 @@ import { PasswordStrengthComponent } from "./shared/components/password-strength
LaunchClickDirective,
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
],
exports: [
A11yInvalidDirective,
@@ -100,7 +102,8 @@ import { PasswordStrengthComponent } from "./shared/components/password-strength
LaunchClickDirective,
UserNamePipe,
PasswordStrengthComponent,
UserTypePipe,
],
providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe],
providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe, UserTypePipe],
})
export class JslibModule {}

View File

@@ -0,0 +1,29 @@
import { Pipe, PipeTransform } from "@angular/core";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
@Pipe({
name: "userType",
})
export class UserTypePipe implements PipeTransform {
constructor(private i18nService: I18nService) {}
transform(value?: OrganizationUserType): string {
if (value == null) {
return this.i18nService.t("unknown");
}
switch (value) {
case OrganizationUserType.Owner:
return this.i18nService.t("owner");
case OrganizationUserType.Admin:
return this.i18nService.t("admin");
case OrganizationUserType.User:
return this.i18nService.t("user");
case OrganizationUserType.Manager:
return this.i18nService.t("manager");
case OrganizationUserType.Custom:
return this.i18nService.t("custom");
}
}
}

View File

@@ -2,8 +2,8 @@ import { Injector, LOCALE_ID, NgModule } from "@angular/core";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/abstractions/account/account-api.service";
import {
InternalAccountService,
AccountService as AccountServiceAbstraction,
InternalAccountService,
} from "@bitwarden/common/abstractions/account/account.service";
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
@@ -41,8 +41,8 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction";
import {
PolicyService as PolicyServiceAbstraction,
InternalPolicyService,
PolicyService as PolicyServiceAbstraction,
} from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/abstractions/provider.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
@@ -121,16 +121,16 @@ import { UnauthGuard } from "../guards/unauth.guard";
import { BroadcasterService } from "./broadcaster.service";
import {
WINDOW,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
SECURE_STORAGE,
STATE_FACTORY,
STATE_SERVICE_USE_CACHE,
LOGOUT_CALLBACK,
LOCKED_CALLBACK,
LOCALES_DIRECTORY,
SYSTEM_LANGUAGE,
LOG_MAC_FAILURES,
WINDOW,
} from "./injection-tokens";
import { ModalService } from "./modal.service";
import { PasswordRepromptService } from "./passwordReprompt.service";

View File

@@ -22,7 +22,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergency-access
import { EmergencyAccessPasswordRequest } from "../models/request/emergency-access-password.request";
import { EmergencyAccessUpdateRequest } from "../models/request/emergency-access-update.request";
import { EventRequest } from "../models/request/event.request";
import { GroupRequest } from "../models/request/group.request";
import { IapCheckRequest } from "../models/request/iap-check.request";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
@@ -105,7 +104,6 @@ import {
EmergencyAccessViewResponse,
} from "../models/response/emergency-access.response";
import { EventResponse } from "../models/response/event.response";
import { GroupDetailsResponse, GroupResponse } from "../models/response/group.response";
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
@@ -121,8 +119,8 @@ import { OrganizationUserBulkPublicKeyResponse } from "../models/response/organi
import { OrganizationUserBulkResponse } from "../models/response/organization-user-bulk.response";
import {
OrganizationUserDetailsResponse,
OrganizationUserUserDetailsResponse,
OrganizationUserResetPasswordDetailsReponse,
OrganizationUserUserDetailsResponse,
} from "../models/response/organization-user.response";
import { PaymentResponse } from "../models/response/payment.response";
import { PlanResponse } from "../models/response/plan.response";
@@ -136,8 +134,8 @@ import {
import { ProviderUserBulkPublicKeyResponse } from "../models/response/provider/provider-user-bulk-public-key.response";
import { ProviderUserBulkResponse } from "../models/response/provider/provider-user-bulk.response";
import {
ProviderUserUserDetailsResponse,
ProviderUserResponse,
ProviderUserUserDetailsResponse,
} from "../models/response/provider/provider-user.response";
import { ProviderResponse } from "../models/response/provider/provider.response";
import { SelectionReadOnlyResponse } from "../models/response/selection-read-only.response";
@@ -156,8 +154,8 @@ import { TwoFactorEmailResponse } from "../models/response/two-factor-email.resp
import { TwoFactorProviderResponse } from "../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../models/response/two-factor-recover.response";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key.response";
import { UserKeyResponse } from "../models/response/user-key.response";
@@ -341,13 +339,8 @@ export abstract class ApiService {
organizationUserId: string
) => Promise<any>;
getGroupDetails: (organizationId: string, id: string) => Promise<GroupDetailsResponse>;
getGroups: (organizationId: string) => Promise<ListResponse<GroupResponse>>;
getGroupUsers: (organizationId: string, id: string) => Promise<string[]>;
postGroup: (organizationId: string, request: GroupRequest) => Promise<GroupResponse>;
putGroup: (organizationId: string, id: string, request: GroupRequest) => Promise<GroupResponse>;
putGroupUsers: (organizationId: string, id: string, request: string[]) => Promise<any>;
deleteGroup: (organizationId: string, id: string) => Promise<any>;
deleteGroupUser: (organizationId: string, id: string, organizationUserId: string) => Promise<any>;
getOrganizationUser: (

View File

@@ -1,8 +0,0 @@
import { SelectionReadOnlyRequest } from "./selection-read-only.request";
export class GroupRequest {
name: string;
accessAll: boolean;
externalId: string;
collections: SelectionReadOnlyRequest[] = [];
}

View File

@@ -1,31 +0,0 @@
import { BaseResponse } from "./base.response";
import { SelectionReadOnlyResponse } from "./selection-read-only.response";
export class GroupResponse extends BaseResponse {
id: string;
organizationId: string;
name: string;
accessAll: boolean;
externalId: string;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.accessAll = this.getResponseProperty("AccessAll");
this.externalId = this.getResponseProperty("ExternalId");
}
}
export class GroupDetailsResponse extends GroupResponse {
collections: SelectionReadOnlyResponse[] = [];
constructor(response: any) {
super(response);
const collections = this.getResponseProperty("Collections");
if (collections != null) {
this.collections = collections.map((c: any) => new SelectionReadOnlyResponse(c));
}
}
}

View File

@@ -29,7 +29,6 @@ import { EmergencyAccessInviteRequest } from "../models/request/emergency-access
import { EmergencyAccessPasswordRequest } from "../models/request/emergency-access-password.request";
import { EmergencyAccessUpdateRequest } from "../models/request/emergency-access-update.request";
import { EventRequest } from "../models/request/event.request";
import { GroupRequest } from "../models/request/group.request";
import { IapCheckRequest } from "../models/request/iap-check.request";
import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request";
import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request";
@@ -114,7 +113,6 @@ import {
} from "../models/response/emergency-access.response";
import { ErrorResponse } from "../models/response/error.response";
import { EventResponse } from "../models/response/event.response";
import { GroupDetailsResponse, GroupResponse } from "../models/response/group.response";
import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response";
import { IdentityTokenResponse } from "../models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
@@ -130,8 +128,8 @@ import { OrganizationUserBulkPublicKeyResponse } from "../models/response/organi
import { OrganizationUserBulkResponse } from "../models/response/organization-user-bulk.response";
import {
OrganizationUserDetailsResponse,
OrganizationUserUserDetailsResponse,
OrganizationUserResetPasswordDetailsReponse,
OrganizationUserUserDetailsResponse,
} from "../models/response/organization-user.response";
import { PaymentResponse } from "../models/response/payment.response";
import { PlanResponse } from "../models/response/plan.response";
@@ -165,8 +163,8 @@ import { TwoFactorEmailResponse } from "../models/response/two-factor-email.resp
import { TwoFactorProviderResponse } from "../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../models/response/two-factor-recover.response";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../models/response/two-factor-yubi-key.response";
import { UserKeyResponse } from "../models/response/user-key.response";
@@ -922,28 +920,6 @@ export class ApiService implements ApiServiceAbstraction {
// Groups APIs
async getGroupDetails(organizationId: string, id: string): Promise<GroupDetailsResponse> {
const r = await this.send(
"GET",
"/organizations/" + organizationId + "/groups/" + id + "/details",
null,
true,
true
);
return new GroupDetailsResponse(r);
}
async getGroups(organizationId: string): Promise<ListResponse<GroupResponse>> {
const r = await this.send(
"GET",
"/organizations/" + organizationId + "/groups",
null,
true,
true
);
return new ListResponse(r, GroupResponse);
}
async getGroupUsers(organizationId: string, id: string): Promise<string[]> {
const r = await this.send(
"GET",
@@ -955,32 +931,6 @@ export class ApiService implements ApiServiceAbstraction {
return r;
}
async postGroup(organizationId: string, request: GroupRequest): Promise<GroupResponse> {
const r = await this.send(
"POST",
"/organizations/" + organizationId + "/groups",
request,
true,
true
);
return new GroupResponse(r);
}
async putGroup(
organizationId: string,
id: string,
request: GroupRequest
): Promise<GroupResponse> {
const r = await this.send(
"PUT",
"/organizations/" + organizationId + "/groups/" + id,
request,
true,
true
);
return new GroupResponse(r);
}
async putGroupUsers(organizationId: string, id: string, request: string[]): Promise<any> {
await this.send(
"PUT",
@@ -991,16 +941,6 @@ export class ApiService implements ApiServiceAbstraction {
);
}
deleteGroup(organizationId: string, id: string): Promise<any> {
return this.send(
"DELETE",
"/organizations/" + organizationId + "/groups/" + id,
null,
true,
false
);
}
deleteGroupUser(organizationId: string, id: string, organizationUserId: string): Promise<any> {
return this.send(
"DELETE",

View File

@@ -0,0 +1,9 @@
<div class="tw-inline-flex tw-gap-2">
<span *ngFor="let item of filteredItems; let last = last" bitBadge [badgeType]="badgeType">
{{ item }}
<span class="tw-sr-only" *ngIf="!last || isFiltered">, </span>
</span>
<span *ngIf="isFiltered" bitBadge [badgeType]="badgeType">
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
</span>
</div>

View File

@@ -0,0 +1,35 @@
import { Component, Input, OnChanges } from "@angular/core";
import { BadgeTypes } from "../badge";
@Component({
selector: "bit-badge-list",
templateUrl: "badge-list.component.html",
})
export class BadgeListComponent implements OnChanges {
private _maxItems: number;
protected filteredItems: string[] = [];
protected isFiltered = false;
@Input() badgeType: BadgeTypes = "primary";
@Input() items: string[] = [];
@Input()
get maxItems(): number | undefined {
return this._maxItems;
}
set maxItems(value: number | undefined) {
this._maxItems = value == undefined ? undefined : Math.max(1, value);
}
ngOnChanges() {
if (this.maxItems == undefined) {
this.filteredItems = this.items;
} else {
this.filteredItems = this.items.slice(0, this.maxItems);
}
this.isFiltered = this.items.length > this.filteredItems.length;
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from "@angular/core";
import { BadgeModule } from "../badge";
import { SharedModule } from "../shared";
import { BadgeListComponent } from "./badge-list.component";
@NgModule({
imports: [SharedModule, BadgeModule],
exports: [BadgeListComponent],
declarations: [BadgeListComponent],
})
export class BadgeListModule {}

View File

@@ -0,0 +1,53 @@
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { BadgeModule } from "../badge";
import { SharedModule } from "../shared";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BadgeListComponent } from "./badge-list.component";
export default {
title: "Component Library/Badge/List",
component: BadgeListComponent,
decorators: [
moduleMetadata({
imports: [SharedModule, BadgeModule],
declarations: [BadgeListComponent],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
plusNMore: (n) => `+ ${n} more`,
});
},
},
],
}),
],
args: {
badgeType: "primary",
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A16956",
},
},
} as Meta;
const ListTemplate: Story<BadgeListComponent> = (args: BadgeListComponent) => ({
props: args,
template: `
<bit-badge-list [badgeType]="badgeType" [maxItems]="maxItems" [items]="items"></bit-badge-list>
`,
});
export const Default = ListTemplate.bind({});
Default.args = {
badgeType: "info",
maxItems: 3,
items: ["Badge 1", "Badge 2", "Badge 3", "Badge 4", "Badge 5"],
};

View File

@@ -0,0 +1 @@
export * from "./badge-list.module";

View File

@@ -1,6 +1,6 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
export type BadgeTypes = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeTypes, string[]> = {
primary: ["tw-bg-primary-500"],

View File

@@ -1,10 +1,17 @@
import { Meta, Story } from "@storybook/angular";
import { CommonModule } from "@angular/common";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { BadgeDirective } from "./badge.directive";
export default {
title: "Component Library/Badge",
component: BadgeDirective,
decorators: [
moduleMetadata({
imports: [CommonModule],
declarations: [BadgeDirective],
}),
],
args: {
badgeType: "primary",
},

View File

@@ -1,2 +1,2 @@
export * from "./badge.directive";
export { BadgeDirective, BadgeTypes } from "./badge.directive";
export * from "./badge.module";

View File

@@ -18,7 +18,7 @@ import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component";
DialogComponent,
SimpleDialogComponent,
],
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent],
exports: [CdkDialogModule, DialogComponent, SimpleDialogComponent, DialogCloseDirective],
providers: [DialogService],
})
export class DialogModule {}

View File

@@ -1,6 +1,7 @@
export * from "./async-actions";
export * from "./avatar";
export * from "./badge";
export * from "./badge-list";
export * from "./banner";
export * from "./button";
export * from "./callout";

View File

@@ -9,10 +9,14 @@ export class I18nMockService implements I18nService {
collator: Intl.Collator;
localeNames: Map<string, string>;
constructor(private lookupTable: Record<string, string>) {}
constructor(private lookupTable: Record<string, string | ((...args: string[]) => string)>) {}
t(id: string, p1?: string, p2?: string, p3?: string) {
return this.lookupTable[id];
const value = this.lookupTable[id];
if (typeof value == "string") {
return value;
}
return value(p1, p2, p3);
}
translate(id: string, p1?: string, p2?: string, p3?: string) {