Refactor i18n approach (#2875)

* Refactor i18n approach

* chore: move to shared

* chore: add default

* create DialogService and update LoadingService (#2876)

* complete translation infra for ui project, currently broken

* cleanup and more dictionaries

* chore: fix

---------

Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2025-04-17 22:00:59 +07:00
committed by GitHub
parent 47b6509f70
commit 50755d8ba3
175 changed files with 4141 additions and 1831 deletions

View File

@@ -1,7 +1,11 @@
import { inject, Injectable } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import {
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import {
@@ -31,10 +35,11 @@ const allowedStatuses = {
})
export class ActionService {
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly dialog = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
async present(data: PackageActionData) {
const { pkgInfo, actionInfo } = data
@@ -46,19 +51,19 @@ export class ActionService {
) {
if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, {
label: actionInfo.metadata.name,
label: actionInfo.metadata.name as i18nKey,
data,
})
} else {
if (actionInfo.metadata.warning) {
this.dialogs
.open(TUI_CONFIRM, {
this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Run',
content: actionInfo.metadata.warning,
content: actionInfo.metadata.warning as i18nKey,
},
})
.pipe(filter(Boolean))
@@ -71,7 +76,6 @@ export class ActionService {
const statuses = [...allowedStatuses[actionInfo.metadata.allowedStatuses]]
const last = statuses.pop()
let statusesStr = statuses.join(', ')
let error = ''
if (statuses.length) {
if (statuses.length > 1) {
// oxford comma
@@ -79,18 +83,14 @@ export class ActionService {
}
statusesStr += ` or ${last}`
} else if (last) {
statusesStr = `${last}`
} else {
error = `There is no status for which this action may be run. This is a bug. Please file an issue with the service maintainer.`
statusesStr = last
}
this.dialogs
.open(
error ||
`Action "${actionInfo.metadata.name}" can only be executed when service is ${statusesStr}`,
this.dialog
.openAlert(
`${this.i18n.transform('Action can only be executed when service is')} ${statusesStr}` as i18nKey,
{
label: 'Forbidden',
size: 's',
},
)
.pipe(filter(Boolean))
@@ -99,7 +99,7 @@ export class ActionService {
}
async execute(packageId: string, actionId: string, input?: object) {
const loader = this.loader.open('Loading...').subscribe()
const loader = this.loader.open('Loading').subscribe()
try {
const res = await this.api.runAction({
@@ -111,14 +111,16 @@ export class ActionService {
if (!res) return
if (res.result) {
this.dialogs
.open(new PolymorpheusComponent(ActionSuccessPage), {
label: res.title,
this.dialog
.openComponent(new PolymorpheusComponent(ActionSuccessPage), {
label: res.title as i18nKey,
data: res,
})
.subscribe()
} else if (res.message) {
this.dialogs.open(res.message, { label: res.title }).subscribe()
this.dialog
.openAlert(res.message as i18nKey, { label: res.title as i18nKey })
.subscribe()
}
} catch (e: any) {
this.errorService.handleError(e)

View File

@@ -1,17 +1,14 @@
import { inject, Injectable } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import {
defaultIfEmpty,
defer,
filter,
firstValueFrom,
of,
switchMap,
} from 'rxjs'
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, defer, filter, firstValueFrom, of } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAllPackages } from 'src/app/utils/get-package-data'
@@ -21,23 +18,25 @@ import { hasCurrentDeps } from 'src/app/utils/has-deps'
providedIn: 'root',
})
export class ControlsService {
private readonly dialogs = inject(TuiDialogService)
private readonly dialog = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly i18n = inject(i18nPipe)
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
const deps = `${title} has unmet dependencies. It will not work as expected.`
const deps =
`${title} ${this.i18n.transform('has unmet dependencies. It will not work as expected.')}` as i18nKey
if (
(unmet && !(await this.alert(deps))) ||
(alerts.start && !(await this.alert(alerts.start)))
(alerts.start && !(await this.alert(alerts.start as i18nKey)))
) {
return
}
const loader = this.loader.open(`Starting...`).subscribe()
const loader = this.loader.open('Starting').subscribe()
try {
await this.api.startPackage({ id })
@@ -49,7 +48,7 @@ export class ControlsService {
}
async stop({ id, title, alerts }: T.Manifest) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
let content = alerts.stop || ''
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
@@ -58,12 +57,20 @@ export class ControlsService {
defer(() =>
content
? this.dialogs
.open(TUI_CONFIRM, getOptions(content, 'Stop'))
? this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
content: content as i18nKey,
yes: 'Stop',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
: of(null),
).subscribe(async () => {
const loader = this.loader.open(`Stopping...`).subscribe()
const loader = this.loader.open('Stopping').subscribe()
try {
await this.api.stopPackage({ id })
@@ -77,17 +84,24 @@ export class ControlsService {
async restart({ id, title }: T.Manifest) {
const packages = await getAllPackages(this.patch)
const options = getOptions(
`Services that depend on ${title} may temporarily experiences issues`,
'Restart',
)
defer(() =>
hasCurrentDeps(id, packages)
? this.dialogs.open(TUI_CONFIRM, options).pipe(filter(Boolean))
? this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
content:
`${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('may temporarily experiences issues')}` as i18nKey,
yes: 'Restart',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
: of(null),
).subscribe(async () => {
const loader = this.loader.open(`Restarting...`).subscribe()
const loader = this.loader.open('Restarting').subscribe()
try {
await this.api.restartPackage({ id })
@@ -99,26 +113,19 @@ export class ControlsService {
})
}
private alert(content: string): Promise<boolean> {
private alert(content: i18nKey): Promise<boolean> {
return firstValueFrom(
this.dialogs
.open<boolean>(TUI_CONFIRM, getOptions(content))
this.dialog
.openConfirm<boolean>({
label: 'Warning',
size: 's',
data: {
content,
yes: 'Continue',
no: 'Cancel',
},
})
.pipe(defaultIfEmpty(false)),
)
}
}
function getOptions(
content: string,
yes = 'Continue',
): Partial<TuiDialogOptions<TuiConfirmData>> {
return {
label: 'Warning',
size: 's',
data: {
content,
yes,
no: 'Cancel',
},
}
}

View File

@@ -1,22 +1,26 @@
import { inject, Injectable, Injector, Type } from '@angular/core'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiConfirmData, TuiConfirmService } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
const PROMPT: Partial<TuiDialogOptions<TuiConfirmData>> = {
label: 'Unsaved Changes',
data: {
content: 'You have unsaved changes. Are you sure you want to leave?',
yes: 'Leave',
no: 'Cancel',
},
}
@Injectable({ providedIn: 'root' })
export class FormDialogService {
private readonly dialogs = inject(TuiDialogService)
private readonly dialog = inject(DialogService)
private readonly i18n = inject(i18nPipe)
private readonly formService = new TuiConfirmService()
private readonly prompt = this.formService.withConfirm(PROMPT)
private readonly PROMPT: Partial<TuiDialogOptions<TuiConfirmData>> = {
label: this.i18n.transform('Unsaved changes'),
data: {
content: this.i18n.transform(
'You have unsaved changes. Are you sure you want to leave?',
),
yes: this.i18n.transform('Leave'),
no: this.i18n.transform('Cancel'),
},
}
private readonly prompt = this.formService.withConfirm(this.PROMPT)
private readonly injector = Injector.create({
parent: inject(Injector),
providers: [
@@ -27,9 +31,14 @@ export class FormDialogService {
],
})
open<T>(component: Type<any>, options: Partial<TuiDialogOptions<T>> = {}) {
this.dialogs
.open(new PolymorpheusComponent(component, this.injector), {
open<T>(
component: Type<any>,
options: Partial<TuiDialogOptions<T>> & {
label?: i18nKey
} = {},
) {
this.dialog
.openComponent(new PolymorpheusComponent(component, this.injector), {
closeable: this.prompt,
dismissible: this.prompt,
...options,

View File

@@ -243,15 +243,15 @@ function listValidators(spec: IST.ValueSpecList): ValidatorFn[] {
return validators
}
function fileValidators(spec: IST.ValueSpecFile): ValidatorFn[] {
const validators: ValidatorFn[] = []
// function fileValidators(spec: IST.ValueSpecFile): ValidatorFn[] {
// const validators: ValidatorFn[] = []
if (spec.required) {
validators.push(Validators.required)
}
// if (spec.required) {
// validators.push(Validators.required)
// }
return validators
}
// return validators
// }
export function numberInRange(
min: number | null,

View File

@@ -49,6 +49,7 @@ export class MarketplaceService {
switchMap(url => this.fetchRegistry$(url)),
filter(Boolean),
map(registry => {
// @TODO Aiden let's drop description. We do not use it. categories should just be Record<string, string>
registry.info.categories = {
all: {
name: 'All',
@@ -178,12 +179,13 @@ export class MarketplaceService {
return from(this.api.getRegistryInfo(url)).pipe(
map(info => ({
...info,
// @TODO Aiden let's drop description. We do not use it. categories should just be Record<string, string>
categories: {
all: {
name: 'All',
description: {
short: 'All services',
long: 'An unfiltered list of all services available on this registry.',
short: '',
long: '',
},
},
...info.categories,

View File

@@ -1,9 +1,9 @@
import { inject, Injectable } from '@angular/core'
import { ErrorService, MARKDOWN } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom, merge, of, shareReplay, Subject } from 'rxjs'
import { REPORT } from 'src/app/components/report.component'
import { REPORT } from 'src/app/components/backup-report.component'
import {
ServerNotification,
ServerNotifications,
@@ -16,7 +16,7 @@ export class NotificationService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly localUnreadCount$ = new Subject<number>()
readonly unreadCount$ = merge(

View File

@@ -7,7 +7,7 @@ import { EOSService } from 'src/app/services/eos.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
// Get data from PatchDb after is starts and act upon it
// @TODO Alex this file has just become checking for StartOS updates. Maybe it can be removed/simplified. I'm not sure why getMarketplace$() line is commented out, I assume we are checking for service updates somewhere else?
@Injectable({
providedIn: 'root',
})
@@ -19,7 +19,6 @@ export class PatchDataService extends Observable<void> {
this.bootstrapper.update(cache)
if (index === 0) {
// check for updates to StartOS and services
this.checkForUpdates()
}
}),

View File

@@ -1,3 +1,4 @@
import { Languages } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
export type DataModel = T.Public & { ui: UIData; packageData: AllPackageData }
@@ -12,7 +13,7 @@ export type UIData = {
}
ackInstructions: Record<string, boolean>
theme: string
language: 'english' | 'spanish'
language: Languages
}
export type UIMarketplaceData = {

View File

@@ -1,6 +1,7 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PkgDependencyErrors } from './dep-error.service'
import { T } from '@start9labs/start-sdk'
import { i18nKey } from '@start9labs/shared'
export interface PackageStatus {
primary: PrimaryStatus
@@ -65,7 +66,7 @@ function getHealthStatus(status: T.MainStatus): T.HealthStatus | null {
}
export interface StatusRendering {
display: string
display: i18nKey
color: string
showDots?: boolean
}
@@ -138,7 +139,7 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
showDots: false,
},
actionRequired: {
display: 'Action Required',
display: 'Task Required',
color: 'warning',
showDots: false,
},

View File

@@ -1,9 +1,13 @@
import { inject, Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { getAllPackages } from '../utils/get-package-data'
@@ -17,13 +21,14 @@ import { DataModel } from './patch-db/data-model'
export class StandardActionsService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly dialog = inject(DialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly router = inject(Router)
private readonly i18n = inject(i18nPipe)
async rebuild(id: string) {
const loader = this.loader.open(`Rebuilding Container...`).subscribe()
const loader = this.loader.open('Rebuilding container').subscribe()
try {
await this.api.rebuildPackage({ id })
@@ -38,18 +43,18 @@ export class StandardActionsService {
async uninstall({ id, title, alerts }: T.Manifest): Promise<void> {
let content =
alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data`
`${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
content = `${content}. ${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
}
this.dialogs
.open(TUI_CONFIRM, {
this.dialog
.openConfirm({
label: 'Warning',
size: 's',
data: {
content,
content: content as i18nKey,
yes: 'Uninstall',
no: 'Cancel',
},
@@ -59,7 +64,7 @@ export class StandardActionsService {
}
private async doUninstall(id: string) {
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
const loader = this.loader.open('Beginning uninstall').subscribe()
try {
await this.api.uninstallPackage({ id })

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'
import { CanActivateFn, IsActiveMatchOptions, Router } from '@angular/router'
import { DialogService } from '@start9labs/shared'
import { TUI_TRUE_HANDLER } from '@taiga-ui/cdk'
import { TuiAlertService } from '@taiga-ui/core'
import {
BehaviorSubject,
combineLatest,
@@ -41,7 +41,7 @@ const OPTIONS: IsActiveMatchOptions = {
providedIn: 'root',
})
export class StateService extends Observable<RR.ServerState | null> {
private readonly alerts = inject(TuiAlertService)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly network$ = inject(NetworkService)
@@ -91,8 +91,8 @@ export class StateService extends Observable<RR.ServerState | null> {
.pipe(
exhaustMap(() =>
concat(
this.alerts
.open('Trying to reach server', {
this.dialog
.openAlert('Trying to reach server', {
label: 'State unknown',
autoClose: 0,
appearance: 'negative',
@@ -104,8 +104,8 @@ export class StateService extends Observable<RR.ServerState | null> {
),
),
),
this.alerts.open('Connection restored', {
label: 'Server reached',
this.dialog.openAlert('Connection restored', {
label: 'Server connected',
appearance: 'positive',
}),
),

View File

@@ -5,6 +5,7 @@ import { combineLatest, map, startWith } from 'rxjs'
import { ConnectionService } from './connection.service'
import { NetworkService } from './network.service'
import { DataModel } from './patch-db/data-model'
import { i18nKey } from '@start9labs/shared'
export const STATUS = new InjectionToken('', {
factory: () =>
@@ -29,33 +30,40 @@ export const STATUS = new InjectionToken('', {
),
})
const OFFLINE = {
const OFFLINE: ServerStatus = {
message: 'No Internet',
color: 'var(--tui-status-negative)',
icon: '@tui.cloud-off',
status: 'error',
}
const CONNECTING = {
const CONNECTING: ServerStatus = {
message: 'Connecting',
color: 'var(--tui-status-warning)',
icon: '@tui.cloud-off',
status: 'warning',
}
const SHUTTING_DOWN = {
message: 'Shutting Down',
const SHUTTING_DOWN: ServerStatus = {
message: 'Shutting down',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
const RESTARTING = {
const RESTARTING: ServerStatus = {
message: 'Restarting',
color: 'var(--tui-status-neutral)',
icon: '@tui.power',
status: 'neutral',
}
const CONNECTED = {
const CONNECTED: ServerStatus = {
message: 'Connected',
color: 'var(--tui-status-positive)',
icon: '@tui.cloud',
status: 'success',
}
type ServerStatus = {
message: i18nKey
color: string
icon: string
status: 'error' | 'warning' | 'neutral' | 'success'
}