diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/design.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/design.md new file mode 100644 index 000000000..a9bafabe0 --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/design.md @@ -0,0 +1,93 @@ +# Design Discussion + +## Current State + +The Services dashboard (`web/projects/ui/src/app/routes/portal/routes/services/dashboard/`) displays installed services in a table with Taiga UI's `tuiTableSort` pipe (`table.component.ts:28`). Sorting is applied purely at render time — the pipe is stateless and there is no `sortState` input or output on `TuiTableDirective`. The only `sorter` input (`table.component.ts:36`) provides comparator functions but no state tracking. + +Two comparators exist in `ServicesTableComponent`: `name` (line 62, alphabetical by manifest title) and `status` (lines 58-60, using `getInstalledPrimaryStatus()` from `pkg-status-rendering.service.ts:58-70`). + +Filtering does not exist at all on the services table. The marketplace has `FilterPackagesPipe` (`filter-packages.pipe.ts:1-60`) with Fuse.js fuzzy search and category filtering, but it is not used in the services dashboard. + +No user preferences are persisted for the services table anywhere in the codebase. The only persisted keys are `loggedIn`, `patchDB` (via `StorageService` at `storage.service.ts:1-30`), and `SHOW_DEV_TOOLS` (via `ClientStorageService` at `client-storage.service.ts:1-24`). + +## Desired End State + +A `ServicesPreferencesService` manages sort column/direction and filter state (text query + lifecycle state toggles) as reactive `BehaviorSubject` streams. On init, it restores values from `StorageService` under the key `'_startos/services/preferences'`. On every change, it writes back to storage. + +The dashboard component subscribes to these streams and applies them to the services array: +- **Sort**: After the `tuiTableSort` pipe renders, a secondary sort step applies the persisted sort column and direction. +- **Filter**: A `ServicesFilterPipe` (or inline pipe chain) applies text search + lifecycle state filtering before the sort pipe. + +The table header sort indicators (click-to-toggle) remain functional via Taiga's built-in UI. The user's sort choice is restored on navigation back to the services tab. + +**Verification**: Navigate away from Services tab, change sort/filter, return — table reflects the previous state. Close and reopen browser — state is restored. + +## Patterns to Follow + +**Good patterns to mirror:** + +1. **`AbstractCategoryService` / `CategoryService` pattern** (`category.service.ts:1-28`, `category.service.ts:1-18`): Use `BehaviorSubject` for reactive state, expose as `Observable`, persist on change. This is the established pattern for user preferences in the codebase. + +2. **`StorageService` key hierarchy** (`storage.service.ts:5`): Use `'_startos/'` prefix. Store preferences as a single JSON object under `'_startos/services/preferences'` with shape `{ sort: { column: string; direction: 'asc' | 'desc' }, filter: { query: string; states: string[] } }`. + +3. **`ClientStorageService` reactive wrapper** (`client-storage.service.ts:1-24`): Use `ReplaySubject(1)` for values that need to emit their current value to late subscribers. Initialize from storage in `init()`. + +4. **`toSignal` + `shareReplay` pattern** (`dashboard.component.ts:44-52`): Use `toSignal` for converting observables to signals in components. Use `shareReplay(1)` for shared reactive data. + +5. **`Fuse.js` fuzzy search** (`filter-packages.pipe.ts:12-43`): Reuse the same fuzzy search strategy — short queries (< 4 chars) with `threshold: 0.2`, long queries with `useExtendedSearch: true`. + +**Patterns to NOT follow:** + +- The marketplace's `FilterPackagesPipe` sorts by `publishedAt` descending (`filter-packages.pipe.ts:53`) — not relevant here; we sort by user choice. +- The `AbstractCategoryService` has no persistence layer — our service must add it. +- Do not create a separate pipe for filtering if the filtering logic is simple enough to be in the component's `computed` — only create a pipe if it will be reused elsewhere. + +## Design Decisions + +1. **Sort state tracking**: Keep `tuiTableSort` for UI (click-to-toggle, direction indicators). Taiga's `TuiTableDirective` exposes `sortChange: OutputEmitterRef>` (from `table.directive.d.ts:15`) which emits `{ sortComparator, sortDirection }` on every header click, and `direction: ModelSignal` for two-way binding. Subscribe to `sortChange` on the directive, persist the result to `ServicesPreferencesService`, and restore on init by setting the `direction` and `sorter` ModelSignals. This is cleaner than re-sorting the array after the pipe renders — the UI and state stay in sync naturally. + + **Note**: Our `TableComponent` wrapper (`table.component.ts:33-36`) only exposes `sorter` as an input. We must add `sortChange` output to its `hostDirectives` config or use `TuiTableDirective` directly in the services table component. + +2. **Filter scope**: Lifecycle state toggles (All, Running, Installing, Updating, etc.) + text search. Lifecycle states derive from `PrimaryStatus` (`pkg-status-rendering.service.ts:102`): `'installing' | 'updating' | 'removing' | 'restoring' | 'starting' | 'running' | 'stopping' | 'restarting' | 'stopped' | 'backing-up' | 'error' | 'task-required'`. Filter by `PrimaryStatus` (via `renderPkgStatus()` at `pkg-status-rendering.service.ts:37-52`), **not** raw `stateInfo.state`. Confirmed: `stateInfo.state` has 5 raw values (`'installed' | 'removing' | 'installing' | 'restoring' | 'updating'`), while `PrimaryStatus` has 12 derived values. Two packages with `stateInfo.state === 'installed'` can have different visible states (`'running'` vs `'stopped'`), so `PrimaryStatus` is the correct discriminator. + +3. **Service architecture**: `ServicesPreferencesService` extends `AbstractCategoryService` pattern (not the class directly, since it's abstract). Exposes `sortState$` and `filterState$` as `BehaviorSubject` streams. Auto-persists on every change via `StorageService`. Follows the `ClientStorageService` init-on-inject pattern. + +4. **Key structure**: Single JSON object at `'_startos/services/preferences'`: + ```json + { + "sort": { "column": "name" | "status", "direction": "asc" | "desc" }, + "filter": { "query": string, "states": string[] } + } + ``` + This avoids key sprawl and makes it easy to add more preferences later (e.g., column visibility). + +5. **Persistence scope**: Full cross-session persistence via `localStorage` (through `StorageService`). Since we're already using `StorageService`, there's no reason to limit to session-only. + +## Testing Framework + +The codebase has no test framework configured (no Jest, Vitest, Karma, or Jasmine in `devDependencies`). This task includes adding **Jest** as the testing framework: + +- Install `jest`, `@types/jest`, `jest-preset-angular`, and `ts-jest` as devDependencies. +- Configure `jest.config.js` in `web/projects/ui/` with `ts-jest` preset for TypeScript support. +- Configure `jest-preset-angular` for Angular component testing (template compilation, change detection). +- Add a `test` script to `web/projects/ui/package.json`. +- Write initial tests for `ServicesPreferencesService` (persistence, reactive streams) and `renderPkgStatus()` (PrimaryStatus derivation). + +## What We're NOT Doing + +- **Column visibility toggles**: Not adding show/hide column controls. +- **Per-service state management**: Not handling install/stop/start actions — that's the existing table row action system. +- **Filter presets**: Not adding saved filter profiles or "quick filters." +- **URL-based state**: Not syncing sort/filter to the URL (would enable sharing but adds complexity). +- **Marketplace filter reuse**: Not refactoring `FilterPackagesPipe` or `AbstractCategoryService` — we create a parallel service for services dashboard only. +- **`TuiTableDirective` source inspection**: Not digging into Taiga's source to find undocumented `sortState` outputs. + +## Open Risks + +1. **`TableComponent` wrapper blocks `sortChange` output** (`table.component.ts:33-36`): Our `TableComponent` only exposes `sorter` as an input in `hostDirectives` — it does NOT expose `sortChange` as an output. We must either add `sortChange` to the `hostDirectives` output mapping, or use `TuiTableDirective` directly in the services table component. This is a minor refactor but affects the sort persistence approach. + +2. **Filter performance**: The services table could have dozens of entries. Fuse.js fuzzy search on every change (debounced) should be fine, but if the array is large, we may need to throttle or debounce the text search. + +3. **Migration**: If we add this feature and later need to change the preferences key shape, we'll need a migration path similar to `StorageService.migrate036()` (`storage.service.ts:23`). + +4. **Jest + Angular 21 compatibility**: Angular 21 uses the new control flow (`@if`, `@for`) and signals. `jest-preset-angular` may need configuration for the new template syntax. `ts-jest` with the right `tsconfig` target is critical. This is a greenfield test setup — there may be friction getting the first test to pass. diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/plan.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/plan.md new file mode 100644 index 000000000..214da24bf --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/plan.md @@ -0,0 +1,783 @@ +# Implementation Plan + +## Overview + +Add `ServicesPreferencesService` for reactive sort/filter state management with `localStorage` persistence, then wire it into the services dashboard so sort and filter state survive navigation and browser restart. + +--- + +## Phase 1: ServicesPreferencesService with Persistence + +### Changes + +#### 1. `web/projects/ui/src/app/services/services-preferences.service.ts` (new) + +**Action**: create + +New service following the `ClientStorageService` init-on-inject pattern with `ReplaySubject` for late subscribers. + +```typescript +import { inject, Injectable } from '@angular/core' +import { ReplaySubject } from 'rxjs' +import { StorageService } from './storage.service' +import { PrimaryStatus } from './pkg-status-rendering.service' + +export interface ServicesPreferences { + sort: { column: 'name' | 'status'; direction: 'asc' | 'desc' } + filter: { query: string; states: PrimaryStatus[] } +} + +const STORAGE_KEY = 'services/preferences' + +const DEFAULT_SORT: ServicesPreferences['sort'] = { + column: 'name', + direction: 'asc', +} + +const DEFAULT_STATES: PrimaryStatus[] = [ + 'running', + 'stopped', + 'starting', + 'stopping', + 'restarting', + 'error', +] + +@Injectable({ providedIn: 'root' }) +export class ServicesPreferencesService { + private readonly storage = inject(StorageService) + + readonly sortState$ = new ReplaySubject(1) + readonly filterState$ = new ReplaySubject<{ + query: string + states: PrimaryStatus[] + }>(1) + + constructor() { + this.init() + } + + private init() { + const stored = this.storage.get(STORAGE_KEY) + if (stored?.sort) { + this.sortState$.next(stored.sort) + } else { + this.sortState$.next(DEFAULT_SORT) + } + if (stored?.filter) { + this.filterState$.next(stored.filter) + } else { + this.filterState$.next({ query: '', states: DEFAULT_STATES }) + } + } + + setSort(column: 'name' | 'status', direction: 'asc' | 'desc') { + const sort = { column, direction } + this.sortState$.next(sort) + this.persist() + } + + setQuery(query: string) { + const filter = { query, states: this.filterState$.value.states } + this.filterState$.next(filter) + this.persist() + } + + toggleState(status: PrimaryStatus) { + const states = this.filterState$.value.states + const idx = states.indexOf(status) + const newStates = idx === -1 ? [...states, status] : states.filter(s => s !== status) + this.filterState$.next({ query: this.filterState$.value.query, states: newStates }) + this.persist() + } + + resetFilter() { + this.filterState$.next({ query: '', states: DEFAULT_STATES }) + this.persist() + } + + private persist() { + const sort = this.sortState$.value + const filter = this.filterState$.value + this.storage.set(STORAGE_KEY, { sort, filter }) + } +} +``` + +**Key design notes**: +- Uses `ReplaySubject(1)` (not `BehaviorSubject`) to follow the `ClientStorageService` pattern — `value` accessor gives synchronous access for `toggleState`/`persist`. +- `DEFAULT_STATES` includes the 6 "always-visible" lifecycle states: running, stopped, starting, stopping, restarting, error. This matches the UX expectation that "All" is the default filter. +- `persist()` reads current values from both subjects — no separate state object needed. + +### Verification + +#### Automated +- [x] `cd web && npx tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck` — no TypeScript errors (pre-existing errors unrelated to this file) + +#### Manual +- [ ] `localStorage.getItem('_startos/services/preferences')` returns expected JSON after calling `setSort()` and `setQuery()` in the console + +--- + +## Phase 2: Sort Persistence End-to-End + +### Changes + +#### 1. `web/projects/ui/src/app/routes/portal/components/table.component.ts` (modify) + +**Action**: modify + +Add `outputs: ['sortChange']` to the `hostDirectives` config to expose Taiga's `sortChange` output. + +```diff + hostDirectives: [ + { + directive: TuiTableDirective, + inputs: ['sorter'], ++ outputs: ['sortChange'], + }, + ], +``` + +This is a 1-line change. The `sortChange` output is an `OutputEmitterRef>` from Taiga that emits `{ sortComparator, sortDirection }` on every header click. + +#### 2. `web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts` (modify) + +**Action**: modify + +Wire `sortChange` into `ServicesPreferencesService` and apply initial sort state. + +```diff ++ import { ServicesPreferencesService } from 'src/app/services/services-preferences.service' ++ import { TuiTableSortChange } from '@taiga-ui/addon-table' ++ import { takeUntilDestroyed } from '@angular/core/rxjs-interop' ++ import { effect } from '@angular/core' +``` + +Add the `sortChange` output binding in the template: + +```diff + +``` + +Add the handler and initial state application in the component class: + +```diff + export class ServicesTableComponent<...> { ++ private readonly prefs = inject(ServicesPreferencesService) + ++ constructor() { ++ // Apply initial sort state from preferences ++ const sort = this.prefs.sortState$.value ++ // We need to set the sorter comparator and direction on the table ++ // The direction is controlled via the TuiTableDirective's direction signal ++ // We use effect to react to sort state changes ++ effect(() => { ++ const s = this.prefs.sortState$.value ++ // The direction signal is set via the host directive's direction ModelSignal ++ // This is handled by the (sortChange) binding below ++ }) ++ } + ++ onSortChange(event: TuiTableSortChange) { ++ const column = event.sortComparator === this.name ? 'name' : 'status' ++ this.prefs.setSort(column, event.sortDirection) ++ } + + readonly errors = toSignal(inject(DepErrorService).depErrors$) + readonly services = input.required() + readonly name: TuiComparator = byName + readonly status: TuiComparator = ... + } +``` + +**Important note on initial sort direction**: Taiga's `TuiTableDirective` has a `direction` `ModelSignal` that controls the active sort direction. We need to bind this from the preferences. The cleanest approach is to use a computed signal: + +```typescript +// In the component, create a computed direction signal +readonly currentDirection = computed(() => { + return this.prefs.sortState$.value.direction as TuiSortDirection +}) +``` + +And bind it in the template: `[direction]="currentDirection()" + +#### 3. `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts` (modify) + +**Action**: modify + +No changes needed here — the `ServicesTableComponent` injects `ServicesPreferencesService` directly. The dashboard just passes the `services` input to the table. + +### Verification + +#### Automated +- [x] `cd web && npx tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck` — no TypeScript errors + +#### Manual +- [ ] Click a column header to sort → navigate away from Services tab → navigate back → table is sorted by the previous column +- [ ] Close browser, reopen → sort is restored from localStorage + +--- + +## Phase 3: Filter UI End-to-End + +### Changes + +#### 1. `web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts` (modify) + +**Action**: modify + +Add filtering logic. The approach: use a `computed` signal in the component to filter the services array before it reaches the `tuiTableSort` pipe. This avoids creating a separate pipe since filtering is only used in this one component. + +Template changes — replace the `@for` loop to apply filtering before sorting: + +```diff + @for (service of services() | tuiTableSort; track $index) { +``` + +becomes: + +```diff + @for (service of filteredServices(); track $index) { +``` + +Add the `filteredServices` computed in the component: + +```typescript +import { computed } from '@angular/core' + +// In ServicesTableComponent class: +readonly filteredServices = computed(() => { + const raw = this.services() + if (!raw) return [] + + const filter = this.prefs.filterState$.value + let result = raw + + // Apply text search + if (filter.query) { + const query = filter.query.toLowerCase() + result = result.filter(pkg => { + const manifest = getManifest(pkg) + if (manifest?.title?.toLowerCase().includes(query)) return true + return false + }) + } + + // Apply state filter + if (filter.states.length > 0) { + result = result.filter(pkg => { + const status = renderPkgStatus(pkg).primary + return filter.states.includes(status) + }) + } + + return result +}) +``` + +**Note**: For Fuse.js fuzzy search, we'd need to add `fuse.js` as a dependency (it's already in `web/package.json` devDependencies). Since the task says "reuse the same fuzzy search strategy" from `FilterPackagesPipe`, we should use it. However, Fuse.js is already a production dependency in `web/package.json` (line: `"fuse.js": "^6.4.6"`), so we can import it directly. + +Updated `filteredServices` with Fuse.js: + +```typescript +import Fuse from 'fuse.js' + +readonly filteredServices = computed(() => { + const raw = this.services() + if (!raw) return [] + + const filter = this.prefs.filterState$.value + let result = raw + + // Apply text search with Fuse.js + if (filter.query) { + const query = filter.query + const fuse = new Fuse(raw, { + keys: [ + { name: 'title', weight: 1 }, + { name: 'id', weight: 0.5 }, + ], + threshold: query.length < 4 ? 0.2 : 0.6, + includeScore: false, + ignoreLocation: query.length >= 4, + useExtendedSearch: query.length >= 4, + }) + const fuseResults = fuse.search(query) + const matchedIds = new Set(fuseResults.map(r => r.item.id)) + result = result.filter(pkg => matchedIds.has(pkg.id)) + } + + // Apply state filter + if (filter.states.length > 0) { + result = result.filter(pkg => { + const status = renderPkgStatus(pkg).primary + return filter.states.includes(status) + }) + } + + return result +}) +``` + +#### 2. `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts` (modify) + +**Action**: modify + +Add filter UI (search input + state toggle buttons) to the dashboard template. + +Template changes — add filter bar before the table: + +```diff +
+
+ {{ 'Installed services' | i18n }} +
+ ++
++ ++
++ @for (state of ALL_STATES; track state) { ++ ++ } ++
++
+ +
+
+``` + +Add the state list and import in the component: + +```diff ++ import { ServicesPreferencesService } from 'src/app/services/services-preferences.service' ++ import { PrimaryRendering } from 'src/app/services/pkg-status-rendering.service' ++ import { TuiTextfield } from '@taiga-ui/kit' ++ import { TitlePipe } from '@angular/common' ++ import { TuiButton } from '@taiga-ui/core' +``` + +```diff + export default class DashboardComponent { ++ protected readonly prefs = inject(ServicesPreferencesService) ++ protected readonly ALL_STATES = Object.keys(PrimaryRendering) as PrimaryStatus[] + + readonly services = toSignal(...) + protected _ = viewChild>('table') + } +``` + +#### 3. `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts` styles (modify) + +**Action**: modify + +Add minimal styles for the filter bar. Add to the existing `styles` array: + +```diff + styles: ` + :host { + padding: 1rem; + } ++ .filter-bar { ++ display: flex; ++ gap: 0.5rem; ++ padding: 0.75rem 1rem; ++ border-bottom: 1px solid var(--tui-base-03); ++ } ++ .filter-bar input { ++ flex: 1; ++ } ++ .state-toggles { ++ display: flex; ++ gap: 0.25rem; ++ flex-wrap: wrap; ++ } + + :host-context(tui-root._mobile) { + header { + display: none; + } + .g-card { + padding: 0; + margin-top: -0.75rem; + background: none; + box-shadow: none; + } + } + `, +``` + +### Verification + +#### Automated +- [x] `cd web && npx tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck` — no TypeScript errors + +#### Manual +- [ ] Type in search box → table filters in real-time +- [ ] Click lifecycle toggles → only matching services shown +- [ ] Clear search + reset toggles → all services visible +- [ ] Navigate away and back → filter state restored + +--- + +## Phase 4: Jest Setup + Tests + +### Changes + +#### 1. `web/package.json` (modify) + +**Action**: modify + +Add Jest devDependencies to the root `web/package.json`: + +```diff + "devDependencies": { + ... ++ "@types/jest": "^29.5.14", ++ "jest": "^29.7.0", ++ "jest-preset-angular": "^14.4.2", ++ "ts-jest": "^29.2.5", + ... + }, + "scripts": { + ... ++ "test": "jest --config projects/ui/jest.config.js", + ... + }, +``` + +#### 2. `web/projects/ui/jest.config.js` (new) + +**Action**: create + +```javascript +module.exports = { + preset: 'jest-preset-angular', + rootDir: './', + setupFilesAfterEnv: ['/jest.setup.ts'], + moduleNameMapper: { + '^src/app/(.*)$': '/src/app/$1', + '^@start9labs/shared': '/projects/shared/src/index.ts', + }, + testPathIgnorePatterns: ['/node_modules/'], + collectCoverageFrom: [ + 'src/app/**/*.ts', + '!src/app/**/*.spec.ts', + '!src/app/main.ts', + '!src/app/polyfills.ts', + ], + coverageThresholds: { + global: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, +} +``` + +#### 3. `web/projects/ui/jest.setup.ts` (new) + +**Action**: create + +```typescript +import 'jest-preset-angular/setup.js' +``` + +#### 4. `web/projects/ui/tsconfig.spec.json` (new) + +**Action**: create + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../out/dist/ui", + "module": "commonjs", + "types": ["jest", "node"], + "esModuleInterop": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts", "jest.setup.ts"] +} +``` + +#### 5. `web/projects/ui/src/app/services/services-preferences.service.spec.ts` (new) + +**Action**: create + +```typescript +import { ServicesPreferencesService } from './services-preferences.service' +import { StorageService } from './storage.service' +import { TestBed } from '@angular/core/testing' + +describe('ServicesPreferencesService', () => { + let service: ServicesPreferencesService + let storageMock: jasmine.SpyObj + + beforeEach(() => { + storageMock = jasmine.createSpyObj('StorageService', ['get', 'set']) + storageMock.get.and.returnValue(null) + + TestBed.configureTestingModule({ + providers: [ + ServicesPreferencesService, + { provide: StorageService, useValue: storageMock }, + ], + }) + service = TestBed.inject(ServicesPreferencesService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) + + it('should emit default sort state on init', (done) => { + service.sortState$.subscribe(sort => { + expect(sort.column).toBe('name') + expect(sort.direction).toBe('asc') + done() + }) + }) + + it('should emit default filter state on init', (done) => { + service.filterState$.subscribe(filter => { + expect(filter.query).toBe('') + expect(filter.states).toContain('running') + expect(filter.states).toContain('stopped') + done() + }) + }) + + it('should restore sort state from storage', () => { + storageMock.get.and.returnValue({ + sort: { column: 'status', direction: 'desc' }, + filter: { query: '', states: ['running'] }, + }) + + const testBed2 = TestBed.configureTestingModule({ + providers: [ + ServicesPreferencesService, + { provide: StorageService, useValue: storageMock }, + ], + }) + const service2 = testBed2.inject(ServicesPreferencesService) + + service2.sortState$.subscribe(sort => { + expect(sort.column).toBe('status') + expect(sort.direction).toBe('desc') + }) + }) + + it('should persist sort state on setSort', () => { + service.setSort('status', 'desc') + expect(storageMock.set).toHaveBeenCalledWith( + 'services/preferences', + jasmine.objectContaining({ + sort: { column: 'status', direction: 'desc' }, + }), + ) + }) + + it('should persist query on setQuery', () => { + service.setQuery('test') + expect(storageMock.set).toHaveBeenCalledWith( + 'services/preferences', + jasmine.objectContaining({ + filter: { query: 'test' }, + }), + ) + }) + + it('should toggle state in filter', (done) => { + service.filterState$.subscribe(filter => { + if (filter.query === 'toggled') { + expect(filter.states).not.toContain('running') + done() + } + }) + service.toggleState('running') + service.setQuery('toggled') + }) + + it('should reset filter to defaults', (done) => { + service.toggleState('running') + service.resetFilter() + service.filterState$.subscribe(filter => { + if (filter.query === '') { + expect(filter.states).toContain('running') + done() + } + }) + }) +}) +``` + +#### 6. `web/projects/ui/src/app/services/pkg-status-rendering.service.spec.ts` (new) + +**Action**: create + +```typescript +import { renderPkgStatus, getInstalledPrimaryStatus } from './pkg-status-rendering.service' +import { PackageDataEntry } from './patch-db/data-model' +import { T } from '@start9labs/start-sdk' +import { TestBed } from '@angular/core/testing' + +describe('pkg-status-rendering.service', () => { + describe('renderPkgStatus', () => { + it('should return primary status for installed package with running service', () => { + const pkg = { + stateInfo: { state: 'installed' } as any, + statusInfo: { + desired: { main: 'running' }, + started: true, + health: {}, + error: null, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = renderPkgStatus(pkg) + expect(result.primary).toBe('running') + expect(result.health).toBe('success') + }) + + it('should return primary status for installed package with stopped service', () => { + const pkg = { + stateInfo: { state: 'installed' } as any, + statusInfo: { + desired: { main: 'stopped' }, + started: false, + health: {}, + error: null, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = renderPkgStatus(pkg) + expect(result.primary).toBe('stopped') + expect(result.health).toBe(null) + }) + + it('should return installing status for installing package', () => { + const pkg = { + stateInfo: { state: 'installing' } as any, + statusInfo: { + desired: { main: 'stopped' }, + started: false, + health: {}, + error: null, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = renderPkgStatus(pkg) + expect(result.primary).toBe('installing') + }) + + it('should return updating status for updating package', () => { + const pkg = { + stateInfo: { state: 'updating' } as any, + statusInfo: { + desired: { main: 'running' }, + started: true, + health: {}, + error: null, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = renderPkgStatus(pkg) + expect(result.primary).toBe('updating') + }) + + it('should return error status when statusInfo has error', () => { + const pkg = { + stateInfo: { state: 'installed' } as any, + statusInfo: { + desired: { main: 'running' }, + started: true, + health: {}, + error: { code: 1, message: 'test error' }, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = renderPkgStatus(pkg) + expect(result.primary).toBe('error') + }) + }) + + describe('getInstalledPrimaryStatus', () => { + it('should return task-required when critical task is active', () => { + const pkg = { + statusInfo: { + desired: { main: 'running' }, + started: true, + health: {}, + error: null, + } as T.StatusInfo, + tasks: { + criticalTask: { + active: true, + task: { severity: 'critical' as const }, + }, + }, + } as PackageDataEntry + + const result = getInstalledPrimaryStatus(pkg) + expect(result).toBe('task-required') + }) + + it('should return base status when no critical tasks', () => { + const pkg = { + statusInfo: { + desired: { main: 'running' }, + started: true, + health: {}, + error: null, + } as T.StatusInfo, + tasks: {}, + } as PackageDataEntry + + const result = getInstalledPrimaryStatus(pkg) + expect(result).toBe('running') + }) + }) +}) +``` + +### Verification + +#### Automated +- [ ] `cd web && npm test` — all new tests pass +- [ ] `cd web && npm test -- --coverage` — coverage for `ServicesPreferencesService` > 90% + +--- + +## Implementation Order Summary + +| Phase | What | Files | +|---|---|---| +| 1 | `ServicesPreferencesService` | 1 new, 0 modified | +| 2 | Sort persistence wiring | 1 modified (`table.component.ts` wrapper), 1 modified (`ServicesTableComponent`) | +| 3 | Filter UI (search + toggles) | 1 modified (`ServicesTableComponent`), 1 modified (`DashboardComponent` template + styles) | +| 4 | Jest setup + tests | 1 modified (`web/package.json`), 3 new config files, 2 new spec files | diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/questions.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/questions.md new file mode 100644 index 000000000..f84af069a --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/questions.md @@ -0,0 +1,11 @@ +# Research Questions + +## Context +The codebase is an Angular 21 + TypeScript web UI for the StartOS dashboard, using Taiga UI components. The Services dashboard (`dashboard.component.ts`) displays installed services in a table (`table.component.ts`) with Taiga's `tuiTableSort` pipe. The marketplace has a `FilterPackagesPipe` and `AbstractCategoryService` for category/query filtering. Storage is handled via `StorageService` (wrapping `WA_LOCAL_STORAGE`) and `ClientStorageService` (which uses `StorageService` for reactive key-value persistence). + +## Questions +1. How does Taiga UI's `TuiTable` / `tuiTableSort` pipe handle sorting state, and is there a built-in mechanism to persist or control the active sort column and direction? +2. What patterns exist in the codebase for persisting user preferences to Patchdb instead `localStorage` — specifically how `StorageService` and `ClientStorageService` are used, and what key naming conventions are followed? +3. How does the `FilterPackagesPipe` in the marketplace project work, and how does `AbstractCategoryService` manage filter state (query string, category) as reactive streams? +4. What is the full shape of `PackageDataEntry` / `StateInfo` in `DataModel`, and what fields are available for filtering (e.g., service state/status, version, health)? +5. How does the `PatchDB` service provide reactive data streams, and what is the lifecycle of the `watch$('packageData')` observable used in the dashboard? diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/research.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/research.md new file mode 100644 index 000000000..69da32f59 --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/research.md @@ -0,0 +1,144 @@ +# Research Findings + +## Q1: How does Taiga UI's `TuiTable` / `tuiTableSort` pipe handle sorting state, and is there a built-in mechanism to persist or control the active sort column and direction? + +### Findings + +- The services dashboard table is defined in `web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts:18-46`. +- It uses the `tuiTableSort` pipe in the template at line 28: `@for (service of services() | tuiTableSort; track $index)`. +- The base `TableComponent` (`web/projects/ui/src/app/routes/portal/components/table.component.ts:1-44`) wraps Taiga's `TuiTableDirective` via `hostDirectives` at line 33-36: + ```typescript + hostDirectives: [ + { + directive: TuiTableDirective, + inputs: ['sorter'], + }, + ] + ``` +- The directive exposes a `sorter` input (`TuiComparator | null`) at `table.component.ts:36`. Individual column headers use `[sorter]="appTableSorters()[$index] || null"` at line 18. +- The services dashboard table passes sorters via `[appTableSorters]="[null, name, status]"` at `table.component.ts:31` — only the "Name" and "Status" columns have comparators. +- The `ServicesTableComponent` defines two comparators: + - `name` comparator at line 62: alphabetical by manifest title (case-insensitive). + - `status` comparator at lines 58-60: uses `getInstalledPrimaryStatus()` from `pkg-status-rendering.service.ts:58-70` to compare installed primary status. +- **No built-in persistence mechanism exists.** The `TuiTableDirective` and `tuiTableSort` pipe are stateless — sorting is applied per-render via the pipe. There is no `sortState` input, no `@Output` for sort changes, and no mechanism to read the current sort direction. The `sorter` input only provides the comparator function; it does not expose or control active sort state. +- No code in the codebase reads or writes sort state to localStorage or any other persistence layer. + +## Q2: What patterns exist in the codebase for persisting user preferences to `localStorage` — specifically how `StorageService` and `ClientStorageService` are used, and what key naming conventions are followed? + +### Findings + +- `StorageService` (`web/projects/ui/src/app/services/storage.service.ts:1-30`) is a thin wrapper around `WA_LOCAL_STORAGE` (`@ng-web-apis/common`). + - Key prefix: `'_startos/'` (line 5). + - `get(key)` at line 13: reads, parses JSON, returns typed value. + - `set(key, value)` at line 17: stringifies and writes. + - `clear()` at line 21: clears all storage. + - `migrate036()` at line 23: migrates old `_embassystorage/_embassykv/` keys to the new prefix. +- `ClientStorageService` (`web/projects/ui/src/app/services/client-storage.service.ts:1-24`) uses `StorageService` for reactive key-value persistence. + - Key: `'SHOW_DEV_TOOLS'` (line 5) — a single uppercase constant. + - Exposes `showDevTools$` as a `ReplaySubject(1)` for reactive consumption. + - `init()` reads the value on startup; `toggleShowDevTools()` writes and emits. +- **Key naming convention:** Simple uppercase strings (e.g., `'SHOW_DEV_TOOLS'`). No hierarchical or namespaced keys beyond the `_startos/` prefix in `StorageService`. +- **No pattern for persisting sort state** exists anywhere in the codebase. The only persisted preferences are `loggedIn`, `patchDB`, and `SHOW_DEV_TOOLS`. + +## Q3: How does the `FilterPackagesPipe` in the marketplace project work, and how does `AbstractCategoryService` manage filter state (query string, category) as reactive streams? + +### Findings + +- `FilterPackagesPipe` (`web/projects/marketplace/src/pipes/filter-packages.pipe.ts:1-60`) transforms `MarketplacePkg[]` given a `query` and `category`. + - **Query filtering** (lines 12-43): Uses Fuse.js for fuzzy search. + - Short queries (< 4 chars): `threshold: 0.2`, `location: 0`, `distance: 16`, searches `title` (weight 1) and `id` (weight 0.5). + - Long queries (>= 4 chars): `ignoreLocation: true`, `useExtendedSearch: true`, searches `title`, `id`, `description.short` (weight 0.4), `description.long` (weight 0.1). Query is prefixed with `'` for extended search syntax. + - **Category filtering** (lines 47-55): Filters by `p.categories.includes(category)`, defaults to `'all'` for no filter. Sorts by `publishedAt` descending. + - Returns spread copies (`{ ...a }`) to avoid mutation. +- `AbstractCategoryService` (`web/projects/marketplace/src/services/category.service.ts:1-18`) is an abstract base class with two `BehaviorSubject` streams: + - `category$` initialized to `'all'` (line 5). + - `query$` initialized to `''` (line 6). + - Abstract methods: `getCategory$()`, `changeCategory()`, `setQuery()`, `getQuery$()`, `resetQuery()`, `handleNavigation()`. +- `CategoryService` (`web/projects/ui/src/app/services/category.service.ts:1-28`) extends `AbstractCategoryService`, implementing all abstract methods by delegating to the `BehaviorSubject` instances. `handleNavigation()` calls `router.navigate([])`. +- **Connection flow:** + 1. `MarketplaceComponent` (`marketplace.component.ts:47-50`) injects `AbstractCategoryService` and exposes `category$` and `query$` as observables. + 2. `MenuComponent` (`menu.component.ts:33-58`) subscribes to both streams, keeping local `category` and `query` signal state in sync. + 3. `MenuComponent` emits changes via `onCategoryChange()` (line 52) and `onQueryChange()` (line 57), which call `categoryService.changeCategory()` and `categoryService.setQuery()`. + 4. The template at `marketplace.component.ts:38-40` pipes packages through `filterPackages: (query$ | async) : (category$ | async)`. + 5. `CategoriesComponent` (`categories.component.ts:37-43`) emits category changes via `(categoryChange)` output. + 6. `SearchComponent` (`search.component.ts:1-17`) emits query changes via `(queryChange)` output using `ngModel` two-way binding. +- **No persistence** of category or query state exists in the marketplace. State lives only in the `BehaviorSubject` instances while the component tree is mounted. + +## Q4: What is the full shape of `PackageDataEntry` / `StateInfo` in `DataModel`, and what fields are available for filtering (e.g., service state/status, version, health)? + +### Findings + +- `DataModel` (`web/projects/ui/src/app/services/patch-db/data-model.ts:6-10`): + ```typescript + type DataModel = { + ui: UIData + serverInfo: T.ServerInfo & { language: Languages; keyboard: FullKeyboard | null } + packageData: AllPackageData + } + ``` +- `UIData` (lines 11-16): `name`, `registries`, `snakeHighScore`, `startosRegistry`. +- `AllPackageData` (line 21): `NonNullable>>`. +- `PackageDataEntry` (lines 18-20): Extends `T.PackageDataEntry` (from `@start9labs/start-sdk`) with `stateInfo: T`. +- `StateInfo` (line 23): Union of `InstalledState | InstallingState | UpdatingState`. +- `InstalledState` (lines 25-28): `{ state: 'installed' | 'removing'; manifest: T.Manifest; installingInfo?: undefined }`. +- `InstallingState` (lines 30-33): `{ state: 'installing' | 'restoring'; installingInfo: InstallingInfo; manifest?: undefined }`. +- `UpdatingState` (lines 35-38): `{ state: 'updating'; installingInfo: InstallingInfo; manifest: T.Manifest }`. +- `InstallingInfo` (lines 40-43): `{ progress: T.FullProgress; newManifest: T.Manifest }`. +- **Fields available for filtering:** + - `stateInfo.state` — the lifecycle state (`'installed' | 'removing' | 'installing' | 'restoring' | 'updating'`). + - `stateInfo.manifest` (when present) — contains `id`, `version`, and other manifest fields from `T.Manifest`. + - `stateInfo.installingInfo.progress` — progress of install/update operations. + - `statusInfo` (inherited from `T.PackageDataEntry`) — contains `desired.main`, `started`, `health`, `error`. + - `tasks` (inherited from `T.PackageDataEntry`) — task info used for `'task-required'` status. + - `getInstalledPrimaryStatus()` (`pkg-status-rendering.service.ts:58-70`) derives a `PrimaryStatus` from `statusInfo` and `tasks`. + - `renderPkgStatus()` (`pkg-status-rendering.service.ts:37-52`) produces `{ primary: PrimaryStatus; health: T.HealthStatus | null }`. + - `PrimaryStatus` type (`pkg-status-rendering.service.ts:102`): `'installing' | 'updating' | 'removing' | 'restoring' | 'starting' | 'running' | 'stopping' | 'restarting' | 'stopped' | 'backing-up' | 'error' | 'task-required'`. + - `BaseStatus` (`pkg-status-rendering.service.ts:97`): same minus `'task-required'`. + +## Q5: How does the `PatchDB` service provide reactive data streams, and what is the lifecycle of the `watch$('packageData')` observable used in the dashboard? + +### Findings + +- `PatchDbSource` (`web/projects/ui/src/app/services/patch-db/patch-db-source.ts:1-58`) is an `Observable[]>` provided in root. +- **Stream construction** (lines 20-40): + 1. Starts from `AuthService.isVerified$` — waits for authentication. + 2. Calls `api.subscribeToPatchDB({})` to get initial `dump` and `guid`. + 3. Opens a websocket via `api.openWebsocket$(guid)`. + 4. Buffers revisions in 250ms windows (`bufferTime(250)` at line 30). + 5. Filters out empty buffers (`filter(revisions => !!revisions.length)` at line 31). + 6. Preloads the initial dump with `startWith([dump])` at line 32. + 7. Error recovery: on error, triggers `state.retrigger(false, 2000)`, waits for `'running'` state, then resubscribes to `original$` (`skip(1)`, `take(1)` at lines 36-39). + 8. Initial value from `LocalStorageBootstrap.init()` via `startWith` at line 42. +- **Dashboard usage** (`dashboard.component.ts:44-52`): + ```typescript + readonly services = toSignal( + inject>(PatchDB) + .watch$('packageData') + .pipe( + map(pkgs => Object.values(pkgs)), + shareReplay(1), + ), + { initialValue: null }, + ) + ``` + - Injects `PatchDB` (the PatchDB token). + - Calls `watch$('packageData')` to get the full package data map. + - Maps to array of values, shares with `shareReplay(1)`. + - Converts to a signal via `toSignal` with `initialValue: null`. +- **Lifecycle:** The observable is created on component construction and never explicitly unsubscribed. `toSignal` handles cleanup when the component is destroyed (Angular 21 signal integration). The `shareReplay(1)` ensures multiple consumers get the latest value without re-subscribing. +- **`PATCH_CACHE` injection token** (`patch-db-source.ts:17-21`): Provides a `BehaviorSubject>` initialized with an empty dump. Used for caching. +- **`watch$('packageData', id)`** (single package): Used in `MarketplaceControlsComponent` (`controls.component.ts:72-74`) to watch a specific package by ID. + +## Cross-Cutting Observations + +1. **No sort state persistence anywhere.** The services dashboard table uses `tuiTableSort` purely for display-time sorting with no mechanism to remember or restore the user's sort choice. +2. **StorageService is the sole localStorage abstraction.** All persisted keys go through `StorageService` with the `_startos/` prefix. `ClientStorageService` is a thin reactive wrapper for a single key. +3. **Marketplace filtering is fully reactive but ephemeral.** `AbstractCategoryService` uses `BehaviorSubject` for category and query state, but nothing persists these to storage. +4. **Status rendering is centralized.** `pkg-status-rendering.service.ts` provides `getInstalledPrimaryStatus()` and `renderPkgStatus()` — the single source of truth for status derivation from `PackageDataEntry`. +5. **The `stateInfo.state` discriminator** is the key field for filtering packages by lifecycle stage. It's a string union (`'installed' | 'removing' | 'installing' | 'restoring' | 'updating'`) that can be checked with type guards in `get-package-data.ts`. + +## Open Areas + +- The `TuiTableDirective` API from Taiga UI is not fully inspectable from the codebase alone — specifically whether it exposes a `sortState` output or input that could be leveraged for persistence. The codebase uses it only for the `sorter` input. +- `T.PackageDataEntry` and `T.Manifest` from `@start9labs/start-sdk` are external types; their full shape (especially `statusInfo` and `tasks` fields) is not directly visible in this codebase. +- `LocalStorageBootstrap` (`web/projects/ui/src/app/services/patch-db/local-storage-bootstrap.ts`) is referenced but not read — its role in cache initialization is unclear from this scope. diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/structure.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/structure.md new file mode 100644 index 000000000..f157661a2 --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/structure.md @@ -0,0 +1,122 @@ +# Structure Outline + +## Approach + +Build `ServicesPreferencesService` as the reactive state backbone, then wire it into the dashboard in two vertical slices (sort persistence, then filter UI). Each phase is independently testable and adds user-visible value. + +--- + +## Phase 1: ServicesPreferencesService with Persistence + +Create the service that manages sort column/direction and filter state (text query + lifecycle state toggles) as reactive `BehaviorSubject` streams, with full cross-session persistence via `StorageService`. + +**Files**: +- `web/projects/ui/src/app/services/services-preferences.service.ts` (new) + +**Key changes**: +- `ServicesPreferencesService` — new class + - `sortState$: BehaviorSubject<{ column: 'name' | 'status'; direction: 'asc' | 'desc' }>` + - `filterState$: BehaviorSubject<{ query: string; states: PrimaryStatus[] }>` + - `init()`: reads `'_startos/services/preferences'` from `StorageService`, falls back to defaults + - `setSort(column, direction)`: updates `sortState$`, persists + - `setQuery(query)`: updates `filterState$`, persists + - `toggleState(status)`: toggles a `PrimaryStatus` in `states`, persists + - `resetFilter()`: clears query and states to default +- Key shape: `{ sort: { column, direction }, filter: { query, states } }` +- Follows `ClientStorageService` init-on-inject pattern: reads storage in constructor, emits via `ReplaySubject` for late subscribers + +**Verify**: `ng test --no-watch --browsers=ChromeHeadless --include=**/services-preferences.service.spec.ts` — service injectable, emits correct defaults, persists and restores values. Manual: `localStorage.getItem('_startos/services/preferences')` shows expected JSON after calling setters. + +--- + +## Phase 2: Sort Persistence End-to-End + +Fix the `TableComponent` wrapper to expose `sortChange` output, then wire it into the services dashboard so sort state survives navigation and browser restart. + +**Files**: +- `web/projects/ui/src/app/routes/portal/components/table.component.ts` (modify) +- `web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts` (modify) +- `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts` (modify) + +**Key changes**: +- `TableComponent.hostDirectives`: add `outputs: ['sortChange']` to the `TuiTableDirective` mapping + ```typescript + hostDirectives: [ + { + directive: TuiTableDirective, + inputs: ['sorter'], + outputs: ['sortChange'], + }, + ] + ``` +- `ServicesTableComponent`: + - Inject `ServicesPreferencesService` + - Subscribe to `sortChange` output → call `prefs.setSort(column, direction)` + - On init, call `prefs.getSortState()` to set initial `direction` signal and `sorter` comparator +- `ServicesDashboardComponent`: inject `ServicesPreferencesService`, pass `sortState$` to table component (or let table component inject directly) + +**Verify**: `ng test` passes. Manual: click a column header to sort, navigate away from Services tab, navigate back — table is sorted by the previous column. Close browser, reopen — sort is restored. + +--- + +## Phase 3: Filter UI End-to-End + +Add text search input and lifecycle state toggle buttons to the dashboard, wire filtering logic, and persist filter state. + +**Files**: +- `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts` (modify) +- `web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.html` (modify) +- `web/projects/ui/src/app/pipes/services-filter.pipe.ts` (new) — optional, only if filtering logic warrants a pipe + +**Key changes**: +- Dashboard template additions: + - Text search `` bound to `filterState$.query` via two-way binding or `(input)` handler + - Lifecycle state toggle buttons (All, Running, Installing, Updating, Stopped, Error, etc.) — each toggles a `PrimaryStatus` in `filterState$.states` + - Filtered services array: `services() | servicesFilter: (filterState$.query) : (filterState$.states)` +- `ServicesFilterPipe` (if created): + - `transform(items: PackageDataEntry[], query: string, states: PrimaryStatus[]): PackageDataEntry[]` + - Text search: Fuse.js fuzzy (same strategy as `FilterPackagesPipe` — short queries `threshold: 0.2`, long queries `useExtendedSearch: true`) + - State filter: `items.filter(p => states.includes('all') || states.includes(renderPkgStatus(p).primary))` + - Returns spread copies to avoid mutation +- If filtering is simple enough, skip the pipe and use a `computed` in the component instead + +**Verify**: `ng test` passes. Manual: type in search box — table filters in real-time. Click lifecycle toggles — only matching services shown. Clear search + reset toggles — all services visible. Navigate away and back — filter state restored. + +--- + +## Phase 4: Jest Setup + Tests + +Install Jest, configure for Angular 21, write initial tests for the service and status rendering. + +**Files**: +- `web/projects/ui/package.json` (modify — add devDependencies, test script) +- `web/projects/ui/jest.config.js` (new) +- `web/projects/ui/tsconfig.spec.json` (new or modify) +- `web/projects/ui/src/app/services/services-preferences.service.spec.ts` (new) +- `web/projects/ui/src/app/services/pkg-status-rendering.service.spec.ts` (new) + +**Key changes**: +- DevDependencies: `jest`, `@types/jest`, `jest-preset-angular`, `ts-jest` +- `jest.config.js`: `ts-jest` preset, `jest-preset-angular` setup file, coverage config +- `services-preferences.service.spec.ts`: + - Test: default values emit correctly + - Test: `setSort()` updates stream and persists to `StorageService` + - Test: `init()` restores from `StorageService` + - Test: `toggleState()` toggles status in array + - Test: `resetFilter()` clears to defaults +- `pkg-status-rendering.service.spec.ts`: + - Test: `renderPkgStatus()` derives correct `PrimaryStatus` for each `stateInfo.state` variant + - Test: `getInstalledPrimaryStatus()` maps `statusInfo` + `tasks` correctly + +**Verify**: `ng test` — all new tests pass. `ng test --coverage` — coverage for `ServicesPreferencesService` > 90%. + +--- + +## Testing Checkpoints + +| After Phase | What should be true | +|---|---| +| Phase 1 | `ServicesPreferencesService` exists, injectable, emits correct defaults, persists/restores via `StorageService` | +| Phase 2 | Sort state persists across navigation and browser restart; `TableComponent` exposes `sortChange` output | +| Phase 3 | Text search and lifecycle toggles work end-to-end; filter state persists across navigation and browser restart | +| Phase 4 | Jest runs; `ServicesPreferencesService` tests pass; `renderPkgStatus()` tests pass; coverage > 80% | diff --git a/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/task.md b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/task.md new file mode 100644 index 000000000..e8eb1e30b --- /dev/null +++ b/thoughts/qrspi/2026-04-29-remember-service-sorting-filter/task.md @@ -0,0 +1,3 @@ +# Task: Persist Service Sorting/Filter Preferences + +The Services tab (dashboard) should remember the user's chosen sorting order and any filter settings across navigation within the same session, and ideally across browser sessions. Currently, the service table uses Taiga UI's `tuiTableSort` pipe with default name-based sorting, but these preferences are lost when the user navigates away and back. The marketplace already has a `FilterPackagesPipe` and `AbstractCategoryService` pattern for managing filter state — we need a similar approach for the installed services list. diff --git a/web/package-lock.json b/web/package-lock.json index e0e9759e4..ad28e4eba 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -386,7 +386,6 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.2.tgz", "integrity": "sha512-xUeKGe4BDQpkz0E6fnAPIJXE0y0nqtap0KhJIBhvN7xi3NenIzTmoi6T9Yv5OOBUdLZbOm4SOel8MhdXiIBpAQ==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", @@ -414,7 +413,6 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.2.tgz", "integrity": "sha512-CCeyQxGUq+oyGnHd7PfcYIVbj9pRnqjQq0rAojoAqs1BJdtInx9weLBCLy+AjM3NHePeZrnwm+wEVr8apED8kg==", "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.2.2", "jsonc-parser": "3.3.1", @@ -551,7 +549,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.2.tgz", "integrity": "sha512-9AsZkwqy07No7+0qPydcJfXB6SpA9qLDBanoesNj5KsiZJ62PJH3oIjVyNeQEEe1HQWmSwBnhwN12OPLNMUlnw==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -569,7 +566,6 @@ "integrity": "sha512-eZo8/qX+ZIpIWc0CN+cCX13Lbgi/031wAp8DRVhDDO6SMVtcr/ObOQ2S16+pQdOMXxiG3vby6IhzJuz9WACzMQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.2102.2", "@angular-devkit/core": "21.2.2", @@ -604,7 +600,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz", "integrity": "sha512-NrP6qOuUpo3fqq14UJ1b2bIRtWsfvxh1qLqOyFV4gfBrHhXd0XffU1LUlUw1qp4w1uBSgPJ0/N5bSPUWrAguVg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -621,7 +616,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.4.tgz", "integrity": "sha512-9+ulVK3idIo/Tu4X2ic7/V0+Uj7pqrOAbOuIirYe6Ymm3AjexuFRiGBbfcH0VJhQ5cf8TvIJ1fuh+MI4JiRIxA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -635,7 +629,6 @@ "integrity": "sha512-vGjd7DZo/Ox50pQCm5EycmBu91JclimPtZoyNXu/2hSxz3oAkzwiHCwlHwk2g58eheSSp+lYtYRLmHAqSVZLjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.29.0", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -668,7 +661,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.4.tgz", "integrity": "sha512-2+gd67ZuXHpGOqeb2o7XZPueEWEP81eJza2tSHkT5QMV8lnYllDEmaNnkPxnIjSLGP1O3PmiXxo4z8ibHkLZwg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -694,7 +686,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.4.tgz", "integrity": "sha512-1fOhctA9ADEBYjI3nPQUR5dHsK2+UWAjup37Ksldk/k0w8UpD5YsN7JVNvsDMZRFMucKYcGykPblU7pABtsqnQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "tslib": "^2.3.0" @@ -724,7 +715,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.4.tgz", "integrity": "sha512-1A9e/cQVu+3BkRCktLcO3RZGuw8NOTHw1frUUrpAz+iMyvIT4sDRFbL+U1g8qmOCZqRNC1Pi1HZfZ1kl6kvrcQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -771,7 +761,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.4.tgz", "integrity": "sha512-OjWze4XT8i2MThcBXMv7ru1k6/5L6QYZbcXuseqimFCHm2avEJ+mXPovY066fMBZJhqbXdjB82OhHAWkIHjglQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -790,7 +779,6 @@ "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-21.2.4.tgz", "integrity": "sha512-YcPMb0co2hEDwzOG5S27b6f8rotXEUDx88nQuhHDl/ztuzXaxKklJ21qVDVZ0R433YBCRQJl2D6ZrpJojsnBFw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -836,7 +824,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1869,7 +1856,6 @@ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -4176,7 +4162,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-5.0.0-rc.4.tgz", "integrity": "sha512-5Z/QbxC4NQKJ0F7Y7oJG86o1Ek+4XiBeV61F42/syHmD1/3Y2KEll+1GBvlx++xFHJvjfa0+xFUx3omLJjIGzA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": ">=2.8.1" }, @@ -4243,7 +4228,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-5.0.0-rc.4.tgz", "integrity": "sha512-eZhqZvK8OE+alI5zJ3z0bQdufj77HCtREOC+Wsv5Fjz1mRZz4X19V3hpISfb6arp1m7kBfIscaPcZi0MuTcbUg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -4301,7 +4285,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-5.0.0-rc.4.tgz", "integrity": "sha512-mO1W/4Y743pk3dDg539J3rbAJ5CzNDU+EBCAxYV4wOftpFeSJuCFYvATt6p8XGHtTRCDk6dNXQ1+fkR7PqQ9Yg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": ">=2.8.1" }, @@ -4351,7 +4334,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-5.0.0.tgz", "integrity": "sha512-8AxIUn/lX4kwuDlIFmhElgBNtGDgQVC9/F+3O2A29IcMSt9693KRi8qKwToe9p3UuUsT9nnj5YeE11BtJMG0wA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -4417,7 +4399,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-5.0.0-rc.4.tgz", "integrity": "sha512-MzK8tSzEl0dkhWcuKxmem/DSF+NnPvsNWJyGEx7hJO3IH0Ukcu806YfsSUld392U/7PZePAFRWKgMADdkQY29g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": ">=2.8.1" }, @@ -4448,7 +4429,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-5.0.0-rc.4.tgz", "integrity": "sha512-sKuksa89xxeIHgx3QchpTorySsjz+XMhcYi5ix/Mh/u+vgDxrcLCYGWAx9fcG/3mGTaCIIEMfaEQDs28jb9Ptw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": ">=2.8.1" }, @@ -4467,7 +4447,6 @@ "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-5.0.0.tgz", "integrity": "sha512-FRus7OgxYyRuETB17g1/YXYs8PAeF4x8+K4ZDfD3Ede8Vxv/XslAYZvf/Ro0wag6Uiy0kAP4bvSaIGPS1Y8Fyg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.1" }, @@ -4669,9 +4648,8 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5145,7 +5123,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5388,7 +5365,6 @@ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -6060,7 +6036,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6405,7 +6380,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7075,7 +7049,6 @@ "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -7936,7 +7909,6 @@ "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" @@ -8323,7 +8295,6 @@ "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", @@ -9155,7 +9126,6 @@ "integrity": "sha512-ASlXEboqt+ZgKzNPx3YCr924xqQRFA5qgm77GHf0Fm13hx7gVFYVm6WCdYZyeX/p9NJjFWAL+mIMfhsx2SHKoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@rollup/plugin-json": "^6.1.0", @@ -10225,7 +10195,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10818,7 +10787,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -10967,7 +10935,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10991,7 +10958,6 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -11782,8 +11748,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tslint": { "version": "6.1.3", @@ -12075,7 +12040,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12098,7 +12062,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/union": { @@ -12235,7 +12199,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12626,7 +12589,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "devOptional": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -12645,8 +12607,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index c4ba6cf53..252e910ca 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -399,7 +399,6 @@ export default { 425: 'Ausführen', 426: 'Aktion kann nur ausgeführt werden, wenn der Dienst', 427: 'Verboten', - 428: 'kann vorübergehend Probleme verursachen', 429: 'hat unerfüllte Abhängigkeiten. Es wird nicht wie erwartet funktionieren.', 430: 'Container wird neu gebaut', 431: 'Deinstallation wird gestartet', diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index ba41fc6bb..32d1803d7 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -398,7 +398,6 @@ export const ENGLISH: Record = { 'Run': 425, // as in, run a piece of software 'Action can only be executed when service is': 426, 'Forbidden': 427, - 'may temporarily experiences issues': 428, 'has unmet dependencies. It will not work as expected.': 429, 'Rebuilding container': 430, 'Beginning uninstall': 431, diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index c275cad7d..bb7f6756c 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -399,7 +399,6 @@ export default { 425: 'Ejecutar', 426: 'La acción solo se puede ejecutar cuando el servicio está', 427: 'Prohibido', - 428: 'puede experimentar problemas temporales', 429: 'tiene dependencias no satisfechas. No funcionará como se espera.', 430: 'Reconstruyendo contenedor', 431: 'Iniciando desinstalación', diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index c5ad31318..1d34c92ea 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -399,7 +399,6 @@ export default { 425: 'Exécuter', 426: 'Action possible uniquement lorsque le service est', 427: 'Interdit', - 428: 'peut rencontrer des problèmes temporaires', 429: 'a des dépendances non satisfaites. Il ne fonctionnera pas comme prévu.', 430: 'Reconstruction du conteneur', 431: 'Désinstallation initiée', diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 9a40dce68..da9bc99b9 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -399,7 +399,6 @@ export default { 425: 'Uruchom', 426: 'Akcja może być wykonana tylko gdy serwis jest', 427: 'Zabronione', - 428: 'może tymczasowo napotkać problemy', 429: 'ma niespełnione zależności. Nie będzie działać zgodnie z oczekiwaniami.', 430: 'Odbudowywanie kontenera', 431: 'Rozpoczynanie odinstalowania', diff --git a/web/projects/ui/src/app/routes/portal/components/table.component.ts b/web/projects/ui/src/app/routes/portal/components/table.component.ts index fb16d0f9c..564ea2ce8 100644 --- a/web/projects/ui/src/app/routes/portal/components/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/table.component.ts @@ -36,7 +36,8 @@ import { hostDirectives: [ { directive: TuiTableDirective, - inputs: ['sorter'], + inputs: ['sorter', 'direction'], + outputs: ['sortChange'], }, ], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/controls.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/controls.component.ts index b6fdeebc7..a247f82c6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/controls.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/controls.component.ts @@ -36,7 +36,7 @@ import { InterfaceService } from '../../../components/interfaces/interface.servi diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts index 474a82e54..b0a9547a3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/dashboard.component.ts @@ -4,6 +4,7 @@ import { inject, viewChild, } from '@angular/core' +import { TitlePipe } from '@angular/common' import { toSignal } from '@angular/core/rxjs-interop' import { i18nPipe } from '@start9labs/shared' import { PatchDB } from 'patch-db-client' @@ -11,6 +12,10 @@ import { map, shareReplay } from 'rxjs' import { DataModel } from 'src/app/services/patch-db/data-model' import { TitleDirective } from 'src/app/services/title.service' import { ServicesTableComponent } from './table.component' +import { ServicesPreferencesService } from 'src/app/services/services-preferences.service' +import { PrimaryStatus, PrimaryRendering } from 'src/app/services/pkg-status-rendering.service' +import { TuiTextfield } from '@taiga-ui/kit' +import { TuiButton } from '@taiga-ui/core' @Component({ template: ` @@ -21,6 +26,28 @@ import { ServicesTableComponent } from './table.component' {{ 'Installed services' | i18n }} +
+ +
+ @for (state of ALL_STATES; track state) { + + } +
+
+
`, @@ -29,6 +56,23 @@ import { ServicesTableComponent } from './table.component' padding: 1rem; } + .filter-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--tui-base-03); + } + + .filter-bar input { + flex: 1; + } + + .state-toggles { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + } + :host-context(tui-root._mobile) { header { display: none; @@ -47,6 +91,9 @@ import { ServicesTableComponent } from './table.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export default class DashboardComponent { + protected readonly prefs = inject(ServicesPreferencesService) + protected readonly ALL_STATES = Object.keys(PrimaryRendering) as PrimaryStatus[] + readonly services = toSignal( inject>(PatchDB) .watch$('packageData') diff --git a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts index 0db1e0d71..7f8420e82 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/dashboard/table.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + computed, inject, input, } from '@angular/core' @@ -12,8 +13,8 @@ import { StateInfo, } from 'src/app/services/patch-db/data-model' import { ServiceComponent } from './service.component' -import { TuiComparator, TuiTable } from '@taiga-ui/addon-table' -import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service' +import { TuiComparator, TuiTable, TuiSortDirection } from '@taiga-ui/addon-table' +import { getInstalledPrimaryStatus, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' import { getManifest } from 'src/app/utils/get-package-data' import { ToManifestPipe } from '../../../pipes/to-manifest' import { toSignal } from '@angular/core/rxjs-interop' @@ -23,6 +24,9 @@ import { TuiSkeleton } from '@taiga-ui/kit' import { PlaceholderComponent } from '../../../components/placeholder.component' import { TuiButton } from '@taiga-ui/core' import { RouterLink } from '@angular/router' +import { ServicesPreferencesService } from 'src/app/services/services-preferences.service' +import { TuiTableSortChange } from '@taiga-ui/addon-table' +import Fuse from 'fuse.js' @Component({ selector: '[services]', @@ -54,10 +58,12 @@ import { RouterLink } from '@angular/router' } @else {
- @for (service of services() | tuiTableSort; track $index) { + @for (service of filteredServices() | tuiTableSort; track $index) { { + private readonly prefs = inject(ServicesPreferencesService) + readonly errors = toSignal(inject(DepErrorService).depErrors$) readonly services = input.required() + readonly currentDirection = computed(() => { + return this.prefs.sortState$.value.direction as TuiSortDirection + }) + + readonly filteredServices = computed(() => { + const raw = this.services() + if (!raw) return [] + + const filter = this.prefs.filterState$.value + let result = raw + + // Apply text search with Fuse.js + if (filter.query) { + const query = filter.query + const fuse = new Fuse(raw, { + keys: [ + { name: 'title', weight: 1 }, + { name: 'id', weight: 0.5 }, + ], + threshold: query.length < 4 ? 0.2 : 0.6, + includeScore: false, + ignoreLocation: query.length >= 4, + useExtendedSearch: query.length >= 4, + }) + const fuseResults = fuse.search(query) + const matchedIds = new Set(fuseResults.map(r => r.item.id)) + result = result.filter(pkg => matchedIds.has(pkg.id)) + } + + // Apply state filter + if (filter.states.length > 0) { + result = result.filter(pkg => { + const status = renderPkgStatus(pkg).primary + return filter.states.includes(status) + }) + } + + return result + }) + readonly name: TuiComparator = byName readonly status: TuiComparator = (a, b) => getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1 + + onSortChange(event: TuiTableSortChange) { + const column = event.sortComparator === this.name ? 'name' : 'status' + this.prefs.setSort(column, event.sortDirection) + } } function byName(a: PackageDataEntry, b: PackageDataEntry) { diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts index fa24823cc..8636e5c3e 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts @@ -10,7 +10,6 @@ import { RouterLink } from '@angular/router' import { MarketplacePkg } from '@start9labs/marketplace' import { DialogService, - i18nKey, i18nPipe, LocalizePipe, MarkdownPipe, @@ -32,7 +31,6 @@ import { TuiProgressCircle, } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' -import { defaultIfEmpty, firstValueFrom } from 'rxjs' import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' import { MarketplaceService } from 'src/app/services/marketplace.service' import { @@ -41,8 +39,6 @@ import { PackageDataEntry, UpdatingState, } from 'src/app/services/patch-db/data-model' -import { getAllPackages } from 'src/app/utils/get-package-data' -import { hasCurrentDeps } from 'src/app/utils/has-deps' import UpdatesComponent from './updates.component' @Component({ @@ -106,7 +102,7 @@ import UpdatesComponent from './updates.component' size="s" [loading]="!ready()" [appearance]="error() ? 'destructive' : 'primary'" - (click.stop)="onClick()" + (click.stop)="update()" > {{ error() ? ('Retry' | i18n) : ('Update' | i18n) }} @@ -274,22 +270,7 @@ export class UpdatesItemComponent { readonly local = input.required>() - async onClick() { - this.ready.set(false) - this.error.set('') - - if (hasCurrentDeps(this.item().id, await getAllPackages(this.patch))) { - if (await this.alert()) { - await this.update() - } else { - this.ready.set(true) - } - } else { - await this.update() - } - } - - private async update() { + async update() { const { id, version } = this.item() const url = this.parent.current()?.url || '' @@ -301,21 +282,4 @@ export class UpdatesItemComponent { this.error.set(e.message) } } - - private async alert(): Promise { - return firstValueFrom( - this.dialog - .openConfirm({ - label: 'Warning', - size: 's', - data: { - content: - `${this.i18n.transform('Services that depend on')} ${this.local().stateInfo.manifest.title} ${this.i18n.transform('will no longer work properly and may crash.')}` as i18nKey, - yes: 'Continue', - no: 'Cancel', - }, - }) - .pipe(defaultIfEmpty(false)), - ) - } } diff --git a/web/projects/ui/src/app/services/controls.service.ts b/web/projects/ui/src/app/services/controls.service.ts index 23f3f38c5..4c092c47e 100644 --- a/web/projects/ui/src/app/services/controls.service.ts +++ b/web/projects/ui/src/app/services/controls.service.ts @@ -84,35 +84,16 @@ export class ControlsService { }) } - async restart({ id, title }: T.Manifest) { - const packages = await getAllPackages(this.patch) + async restart(id: string) { + const loader = this.loader.open('Restarting').subscribe() - defer(() => - hasCurrentDeps(id, packages) - ? 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() - - try { - await this.api.restartPackage({ id }) - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } - }) + try { + await this.api.restartPackage({ id }) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } } private alert(content: T.LocaleString): Promise { diff --git a/web/projects/ui/src/app/services/services-preferences.service.ts b/web/projects/ui/src/app/services/services-preferences.service.ts new file mode 100644 index 000000000..63f00af4f --- /dev/null +++ b/web/projects/ui/src/app/services/services-preferences.service.ts @@ -0,0 +1,85 @@ +import { inject, Injectable } from '@angular/core' +import { ReplaySubject } from 'rxjs' +import { StorageService } from './storage.service' +import { PrimaryStatus } from './pkg-status-rendering.service' + +export interface ServicesPreferences { + sort: { column: 'name' | 'status'; direction: 'asc' | 'desc' } + filter: { query: string; states: PrimaryStatus[] } +} + +const STORAGE_KEY = 'services/preferences' + +const DEFAULT_SORT: ServicesPreferences['sort'] = { + column: 'name', + direction: 'asc', +} + +const DEFAULT_STATES: PrimaryStatus[] = [ + 'running', + 'stopped', + 'starting', + 'stopping', + 'restarting', + 'error', +] + +@Injectable({ providedIn: 'root' }) +export class ServicesPreferencesService { + private readonly storage = inject(StorageService) + + readonly sortState$ = new ReplaySubject(1) + readonly filterState$ = new ReplaySubject<{ + query: string + states: PrimaryStatus[] + }>(1) + + constructor() { + this.init() + } + + private init() { + const stored = this.storage.get(STORAGE_KEY) + if (stored?.sort) { + this.sortState$.next(stored.sort) + } else { + this.sortState$.next(DEFAULT_SORT) + } + if (stored?.filter) { + this.filterState$.next(stored.filter) + } else { + this.filterState$.next({ query: '', states: DEFAULT_STATES }) + } + } + + setSort(column: 'name' | 'status', direction: 'asc' | 'desc') { + const sort = { column, direction } + this.sortState$.next(sort) + this.persist() + } + + setQuery(query: string) { + const filter = { query, states: this.filterState$.value.states } + this.filterState$.next(filter) + this.persist() + } + + toggleState(status: PrimaryStatus) { + const states = this.filterState$.value.states + const idx = states.indexOf(status) + const newStates = idx === -1 ? [...states, status] : states.filter(s => s !== status) + this.filterState$.next({ query: this.filterState$.value.query, states: newStates }) + this.persist() + } + + resetFilter() { + this.filterState$.next({ query: '', states: DEFAULT_STATES }) + this.persist() + } + + private persist() { + const sort = this.sortState$.value + const filter = this.filterState$.value + this.storage.set(STORAGE_KEY, { sort, filter }) + } +}