From 621b8e6c2a0de77d2573bc9ce9f09d1547c462fc Mon Sep 17 00:00:00 2001 From: Lyubov Voloshko Date: Thu, 15 Oct 2026 11:52:37 +0000 Subject: [PATCH 01/22] Scaffold Vue frontend (migration phase 0) Add frontend-vue/ with Vite, Vue 3, Pinia, vue-router, Vuetify, TanStack Query and Vitest, alongside the Angular app: - Vite modes and .env files mirroring the Angular environments; SaaS modes serve index.saas.html - Runtime config.json loader, /api dev proxy, version script - Lazy Monaco (Vite-bundled workers) and Cedar wasm loaders - biome + eslint-plugin-vue lint, vue-tsc, unit tests - frontend-vue CI workflow Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/frontend-vue.yml | 53 + .gitignore | 3 + VUE_MIGRATION_PLAN.md | 134 ++ frontend-vue/.env | 8 + frontend-vue/.env.development | 6 + frontend-vue/.env.production | 5 + frontend-vue/.env.saas | 8 + frontend-vue/.env.saas-production | 7 + frontend-vue/.gitignore | 6 + frontend-vue/README.md | 22 + frontend-vue/biome.json | 21 + frontend-vue/eslint.config.js | 25 + frontend-vue/index.html | 17 + frontend-vue/index.saas.html | 182 ++ frontend-vue/package.json | 49 + frontend-vue/pnpm-lock.yaml | 2789 ++++++++++++++++++++++++ frontend-vue/pnpm-workspace.yaml | 7 + frontend-vue/public/config.json | 5 + frontend-vue/scripts/update-version.js | 16 + frontend-vue/src/App.spec.ts | 19 + frontend-vue/src/App.vue | 5 + frontend-vue/src/config.spec.ts | 37 + frontend-vue/src/config.ts | 34 + frontend-vue/src/env.d.ts | 14 + frontend-vue/src/environment.spec.ts | 37 + frontend-vue/src/environment.ts | 31 + frontend-vue/src/lib/cedar-wasm.ts | 19 + frontend-vue/src/lib/monaco.ts | 40 + frontend-vue/src/main.ts | 22 + frontend-vue/src/plugins/vuetify.ts | 9 + frontend-vue/src/router/index.ts | 15 + frontend-vue/src/test-setup.ts | 8 + frontend-vue/src/version.ts | 2 + frontend-vue/src/views/HomeView.vue | 10 + frontend-vue/tsconfig.app.json | 13 + frontend-vue/tsconfig.json | 4 + frontend-vue/tsconfig.node.json | 14 + frontend-vue/vite.config.ts | 67 + 38 files changed, 3763 insertions(+) create mode 100644 .github/workflows/frontend-vue.yml create mode 100644 VUE_MIGRATION_PLAN.md create mode 100644 frontend-vue/.env create mode 100644 frontend-vue/.env.development create mode 100644 frontend-vue/.env.production create mode 100644 frontend-vue/.env.saas create mode 100644 frontend-vue/.env.saas-production create mode 100644 frontend-vue/.gitignore create mode 100644 frontend-vue/README.md create mode 100644 frontend-vue/biome.json create mode 100644 frontend-vue/eslint.config.js create mode 100644 frontend-vue/index.html create mode 100644 frontend-vue/index.saas.html create mode 100644 frontend-vue/package.json create mode 100644 frontend-vue/pnpm-lock.yaml create mode 100644 frontend-vue/pnpm-workspace.yaml create mode 100644 frontend-vue/public/config.json create mode 100644 frontend-vue/scripts/update-version.js create mode 100644 frontend-vue/src/App.spec.ts create mode 100644 frontend-vue/src/App.vue create mode 100644 frontend-vue/src/config.spec.ts create mode 100644 frontend-vue/src/config.ts create mode 100644 frontend-vue/src/env.d.ts create mode 100644 frontend-vue/src/environment.spec.ts create mode 100644 frontend-vue/src/environment.ts create mode 100644 frontend-vue/src/lib/cedar-wasm.ts create mode 100644 frontend-vue/src/lib/monaco.ts create mode 100644 frontend-vue/src/main.ts create mode 100644 frontend-vue/src/plugins/vuetify.ts create mode 100644 frontend-vue/src/router/index.ts create mode 100644 frontend-vue/src/test-setup.ts create mode 100644 frontend-vue/src/version.ts create mode 100644 frontend-vue/src/views/HomeView.vue create mode 100644 frontend-vue/tsconfig.app.json create mode 100644 frontend-vue/tsconfig.json create mode 100644 frontend-vue/tsconfig.node.json create mode 100644 frontend-vue/vite.config.ts diff --git a/.github/workflows/frontend-vue.yml b/.github/workflows/frontend-vue.yml new file mode 100644 index 000000000..5801d6b3c --- /dev/null +++ b/.github/workflows/frontend-vue.yml @@ -0,0 +1,53 @@ +name: frontend-vue + +on: + push: + branches: + - main + paths: + - 'frontend-vue/**' + - 'frontend/src/assets/**' + - '.github/workflows/frontend-vue.yml' + pull_request: + paths: + - 'frontend-vue/**' + - 'frontend/src/assets/**' + - '.github/workflows/frontend-vue.yml' + +concurrency: + group: frontend-vue-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: frontend-vue + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + package_json_file: frontend-vue/package.json + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + cache-dependency-path: frontend-vue/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Typecheck + run: pnpm typecheck + - name: Unit tests + run: pnpm test:ci + - name: Build (self-hosted) + run: pnpm build + - name: Build (SaaS) + run: pnpm build:saas diff --git a/.gitignore b/.gitignore index 3cea27708..63d70992f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,12 @@ /out-tsc # Only exists if Bazel was run /bazel-out +/frontend-vue/dist +/frontend-vue/coverage # dependencies /node_modules +/frontend-vue/node_modules .yarn/ # yarn berry cache (not tracked at repo root) diff --git a/VUE_MIGRATION_PLAN.md b/VUE_MIGRATION_PLAN.md new file mode 100644 index 000000000..92d9655e5 --- /dev/null +++ b/VUE_MIGRATION_PLAN.md @@ -0,0 +1,134 @@ +# Plan: Migrate RocketAdmin frontend from Angular to Vue.js + +## Context + +The RocketAdmin frontend (`frontend/`) is an **Angular 20** single-page app. It uses standalone components, Angular Material (M2 theming) and Vitest. The goal is to rebuild it in **Vue 3** with no change in features, routes, API contract or deployment shape, so users don't notice the switch. This plan covers the target stack, the order of work and how to check that the Vue app matches the Angular one. + +**Current size** (from exploring the codebase): +- 250 components, 27 services, 4 guards, 2 interceptors and 9 validator directives. +- About 47k lines of TypeScript, 13k lines of HTML and 22k lines of CSS. +- 258 spec files and 159 Storybook stories. +- **Angular Material is everywhere:** 1,734 `` tags, 47 dialog components and 76 files that use the snackbar. +- **Field widgets:** about 119 components in 4 families (`record-edit-fields`, `record-view-fields`, `table-display-fields`, `filter-fields`, each with 23–32 types). They are rendered dynamically through `ng-dynamic-component` and the type maps in `src/app/consts/*-types.ts`. +- **Forms are mostly template-driven:** 617 `ngModel` uses, 12 reactive forms and 8 custom validator directives. +- **State uses two patterns:** legacy BehaviorSubject event-bus services (`connections`, `tables`, `user`, `auth`, `company`, ...) and newer signal/`rxResource` services (`dashboards`, `saved-queries`, `users`, `cedar-permission`, ...). +- **Heavy third-party libraries:** Monaco (via `@ngstack/code-editor`, 10 places), Chart.js (via ng2-charts), angular-gridster2, ngx-markdown with mermaid, Cedar wasm, Sentry, PostHog, angulartics2/amplitude and Turnstile. +- **Auth uses an httpOnly cookie.** The client only keeps `localStorage.token_expiration`. `token.interceptor.ts` adds the base URL, `withCredentials`, the `GCLID` and `masterpwd` headers, and redirects to `/login` on a 401. +- **Deployment:** the root `Dockerfile` builds the frontend (`production` or `saas-production`), copies `dist/rocketadmin/browser` to `/var/www/html`, and nginx serves it with the SPA fallback and the `/api` proxy. + +## Recommended approach + +**A parallel rewrite in a new `frontend-vue/` directory**, built feature by feature. The Angular app stays in production until the Vue app reaches feature parity. Then the switch happens in the Dockerfile in one step, and Angular can be switched back on in the same step if needed. + +Why I'm not recommending a gradual, route-by-route hybrid: the app shell, sidenav, dialogs and theme are shared by every page. Mixing Angular and Vue on the same pages (micro-frontends, or web components through `defineCustomElement`) would mean keeping two runtimes and two theming systems in sync. That costs more than a clean parallel build. + +### Target stack + +| Concern | Angular today | Vue target | +|---|---|---| +| Framework | Angular 20 standalone | **Vue 3.5+**, ` + + diff --git a/frontend-vue/index.saas.html b/frontend-vue/index.saas.html new file mode 100644 index 000000000..af05899e3 --- /dev/null +++ b/frontend-vue/index.saas.html @@ -0,0 +1,182 @@ + + + + + Rocketadmin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + +
+
+ +
+ + + diff --git a/frontend-vue/package.json b/frontend-vue/package.json new file mode 100644 index 000000000..785de15a4 --- /dev/null +++ b/frontend-vue/package.json @@ -0,0 +1,49 @@ +{ + "name": "rocketadmin-vue", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/update-version.js && vite --mode development", + "dev:saas": "node scripts/update-version.js && vite --mode saas", + "build": "node scripts/update-version.js && vite build --mode production", + "build:saas": "node scripts/update-version.js && vite build --mode saas-production", + "preview": "vite preview", + "typecheck": "vue-tsc --build", + "lint": "biome check . && eslint .", + "test": "vitest", + "test:ci": "vitest run", + "update-version": "node scripts/update-version.js" + }, + "dependencies": { + "@cedar-policy/cedar-wasm": "^4.9.1", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/noto-sans": "^5.2.10", + "@material-symbols/font-400": "^0.40.2", + "@tanstack/vue-query": "^5.104.2", + "monaco-editor": "0.55.1", + "pinia": "^3.0.4", + "vue": "^3.5.43", + "vue-router": "^4.6.4", + "vuetify": "^3.13.6" + }, + "devDependencies": { + "@biomejs/biome": "2.4.0", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.10", + "@vue/test-utils": "^2.5.1", + "@vue/tsconfig": "^0.8.1", + "eslint": "^10.12.0", + "eslint-plugin-vue": "^10.11.1", + "jsdom": "^27.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.71.1", + "vite": "^8.3.4", + "vite-plugin-static-copy": "^4.1.1", + "vite-plugin-vuetify": "^2.1.3", + "vitest": "^5.0.4", + "vue-eslint-parser": "^10.2.0", + "vue-tsc": "^3.3.13" + }, + "packageManager": "pnpm@10.33.0" +} diff --git a/frontend-vue/pnpm-lock.yaml b/frontend-vue/pnpm-lock.yaml new file mode 100644 index 000000000..14ac6991d --- /dev/null +++ b/frontend-vue/pnpm-lock.yaml @@ -0,0 +1,2789 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@cedar-policy/cedar-wasm': + specifier: ^4.9.1 + version: 4.13.0 + '@fontsource/ibm-plex-mono': + specifier: ^5.2.7 + version: 5.3.0 + '@fontsource/noto-sans': + specifier: ^5.2.10 + version: 5.3.0 + '@material-symbols/font-400': + specifier: ^0.40.2 + version: 0.40.2 + '@tanstack/vue-query': + specifier: ^5.104.2 + version: 5.104.2(vue@3.5.43(typescript@5.9.3)) + monaco-editor: + specifier: 0.55.1 + version: 0.55.1 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) + vue: + specifier: ^3.5.43 + version: 3.5.43(typescript@5.9.3) + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.43(typescript@5.9.3)) + vuetify: + specifier: ^3.13.6 + version: 3.13.6(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.43(typescript@5.9.3)) + devDependencies: + '@biomejs/biome': + specifier: 2.4.0 + version: 2.4.0 + '@types/node': + specifier: ^24.0.0 + version: 24.19.2 + '@vitejs/plugin-vue': + specifier: ^6.0.10 + version: 6.0.10(vite@8.3.4(@types/node@24.19.2))(vue@3.5.43(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.5.1 + version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)) + '@vue/tsconfig': + specifier: ^0.8.1 + version: 0.8.1(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) + eslint: + specifier: ^10.12.0 + version: 10.12.0 + eslint-plugin-vue: + specifier: ^10.11.1 + version: 10.11.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@5.9.3))(eslint@10.12.0)(vue-eslint-parser@10.4.1(eslint@10.12.0)) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.71.1 + version: 8.71.1(eslint@10.12.0)(typescript@5.9.3) + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.2) + vite-plugin-static-copy: + specifier: ^4.1.1 + version: 4.1.1(vite@8.3.4(@types/node@24.19.2)) + vite-plugin-vuetify: + specifier: ^2.1.3 + version: 2.1.3(vite@8.3.4(@types/node@24.19.2))(vue@3.5.43(typescript@5.9.3))(vuetify@3.13.6) + vitest: + specifier: ^5.0.4 + version: 5.0.4(@types/node@24.19.2)(jsdom@27.4.0)(vite@8.3.4(@types/node@24.19.2)) + vue-eslint-parser: + specifier: ^10.2.0 + version: 10.4.1(eslint@10.12.0) + vue-tsc: + specifier: ^3.3.13 + version: 3.3.13(typescript@5.9.3) + +packages: + + '@acemir/cssom@0.9.32': + resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==} + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.4.0': + resolution: {integrity: sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.0': + resolution: {integrity: sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.0': + resolution: {integrity: sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.0': + resolution: {integrity: sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.0': + resolution: {integrity: sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.0': + resolution: {integrity: sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.0': + resolution: {integrity: sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.0': + resolution: {integrity: sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.0': + resolution: {integrity: sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@cedar-policy/cedar-wasm@4.13.0': + resolution: {integrity: sha512-1omEd/J0c7RjcfSUWiXZc6zk6XUs1IuY7Yk9TVysoKV/LpFWIk45RpjvRFVioPUR3RQmgA/DaqKhK3pucbBx1A==} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@fontsource/noto-sans@5.3.0': + resolution: {integrity: sha512-fBCog2PY7DiVVTEEqtI/Qdinx/knobHYfoGpjFXdfBX5RoJaBp1Prw2G75p0OIsdlMZg3cLo0c+YbIUYOxnQJw==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@material-symbols/font-400@0.40.2': + resolution: {integrity: sha512-Ibyypche5VmVB0ZNZd9Ytt9BSHhNr34ZTbFycfXY4Gnbs6mf+jYRblTGosHWWzw010mhJMewfnuW8nnk1XI9Ag==} + + '@one-ini/wasm@0.2.1': + resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tanstack/match-sorter-utils@9.2.6': + resolution: {integrity: sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw==} + engines: {node: '>=20'} + + '@tanstack/query-core@5.104.2': + resolution: {integrity: sha512-/qbsxJK5c3Ve1aFIULWYNcT2jeRUAiD4HME6Eu2ZdpofHoVgAjpkQhLg5xHuOoBkTPQUJLfP7zOxy0qsBcqROA==} + + '@tanstack/vue-query@5.104.2': + resolution: {integrity: sha512-xTJpUMYenXuFstlgRSug10HPcc2TgAfughAxr7ftvaxsuHIZ4jfHQy/uzk99ejJWbrzpPdXiGC9VWtNd/Qvfew==} + peerDependencies: + '@vue/composition-api': ^1.1.2 + vue: ^2.6.0 || ^3.3.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-vue@6.0.10': + resolution: {integrity: sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 || ^3.6.0-rc.10 + + '@vitest/mocker@5.0.4': + resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.4': + resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/language-core@3.3.13': + resolution: {integrity: sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vue/test-utils@2.5.1': + resolution: {integrity: sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + '@vue/tsconfig@0.8.1': + resolution: {integrity: sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@vuetify/loader-shared@2.1.2': + resolution: {integrity: sha512-X+1jBLmXHkpQEnC0vyOb4rtX2QSkBiFhaFXz8yhQqN2A4vQ6k2nChxN4Ol7VAY5KoqMdFoRMnmNdp/1qYXDQig==} + peerDependencies: + vue: ^3.0.0 + vuetify: '>=3' + + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + copy-anything@4.1.5: + resolution: {integrity: sha512-vePst2VLTSqPglW7Q98VwFpGiGpOHTyXIo81MO5VyLecA5zMbZw/P/V1vmwD1jksa2iJpn/YkDrJ3XhTfzUyhQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + editorconfig@3.0.2: + resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} + engines: {node: '>=20'} + hasBin: true + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-vue@10.11.1: + resolution: {integrity: sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.12.0: + resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.12: + resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-beautify@2.0.3: + resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@7.1.1: + resolution: {integrity: sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==} + engines: {node: '>=18'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} + engines: {node: '>=4'} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + remove-accents@0.5.0: + resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@6.2.2: + resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + upath@2.0.1: + resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==} + engines: {node: '>=4'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-plugin-static-copy@4.1.1: + resolution: {integrity: sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==} + engines: {node: ^22.0.0 || >=24.0.0} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-vuetify@2.1.3: + resolution: {integrity: sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: '>=5' + vue: ^3.0.0 + vuetify: '>=3' + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.4: + resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.4 + '@vitest/browser-preview': 5.0.4 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.4 + '@vitest/coverage-v8': 5.0.4 + '@vitest/ui': 5.0.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-component-type-helpers@3.3.13: + resolution: {integrity: sha512-02KCxSmKDhIITjKyJ4QT5csP6L0WoRborLqY/34a40+S9uvMvdNgMV0yVjO3p2Gkz3ZoPmFz/0JsGHacwJYTxw==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@3.3.13: + resolution: {integrity: sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vuetify@3.13.6: + resolution: {integrity: sha512-Rt5dHb5zjiIOdZ/10/IVsTzLdKPLplVMv2hDXx2+LMHMFtt6Ks3NTA2eKqXBWVZZ5ZVIRdJBNBSP4/Iri8UdnA==} + peerDependencies: + typescript: '>=4.7' + vite-plugin-vuetify: '>=2.1.0' + vue: ^3.5.0 + webpack-plugin-vuetify: '>=3.1.0' + peerDependenciesMeta: + typescript: + optional: true + vite-plugin-vuetify: + optional: true + webpack-plugin-vuetify: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@acemir/cssom@0.9.32': {} + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@biomejs/biome@2.4.0': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.0 + '@biomejs/cli-darwin-x64': 2.4.0 + '@biomejs/cli-linux-arm64': 2.4.0 + '@biomejs/cli-linux-arm64-musl': 2.4.0 + '@biomejs/cli-linux-x64': 2.4.0 + '@biomejs/cli-linux-x64-musl': 2.4.0 + '@biomejs/cli-win32-arm64': 2.4.0 + '@biomejs/cli-win32-x64': 2.4.0 + + '@biomejs/cli-darwin-arm64@2.4.0': + optional: true + + '@biomejs/cli-darwin-x64@2.4.0': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.0': + optional: true + + '@biomejs/cli-linux-arm64@2.4.0': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.0': + optional: true + + '@biomejs/cli-linux-x64@2.4.0': + optional: true + + '@biomejs/cli-win32-arm64@2.4.0': + optional: true + + '@biomejs/cli-win32-x64@2.4.0': + optional: true + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@cedar-policy/cedar-wasm@4.13.0': {} + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0)': + dependencies: + eslint: 10.12.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.16.0': {} + + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@fontsource/noto-sans@5.3.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@material-symbols/font-400@0.40.2': {} + + '@one-ini/wasm@0.2.1': {} + + '@oxc-project/types@0.153.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tanstack/match-sorter-utils@9.2.6': + dependencies: + remove-accents: 0.5.0 + + '@tanstack/query-core@5.104.2': {} + + '@tanstack/vue-query@5.104.2(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@tanstack/match-sorter-utils': 9.2.6 + '@tanstack/query-core': 5.104.2 + '@vue/devtools-api': 6.6.4 + vue: 3.5.43(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3)) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@5.9.3))(eslint@10.12.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 + eslint: 10.12.0 + ignore: 7.0.12 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.1 + debug: 4.4.3 + eslint: 10.12.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.12.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.71.1': {} + + '@typescript-eslint/typescript-estree@8.71.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@5.9.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.1(eslint@10.12.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + eslint: 10.12.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.71.1': + dependencies: + '@typescript-eslint/types': 8.71.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue@6.0.10(vite@8.3.4(@types/node@24.19.2))(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.2) + vue: 3.5.43(typescript@5.9.3) + + '@vitest/mocker@5.0.4(vite@8.3.4(@types/node@24.19.2))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.4 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@24.19.2) + + '@vitest/spy@5.0.4': {} + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.29 + source-map-js: 1.2.2 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@3.3.13': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.43 + js-beautify: 2.0.3 + vue: 3.5.43(typescript@5.9.3) + vue-component-type-helpers: 3.3.13 + optionalDependencies: + '@vue/server-renderer': 3.5.43 + + '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.43(typescript@5.9.3) + + '@vuetify/loader-shared@2.1.2(vue@3.5.43(typescript@5.9.3))(vuetify@3.13.6)': + dependencies: + upath: 2.0.1 + vue: 3.5.43(typescript@5.9.3) + vuetify: 3.13.6(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.43(typescript@5.9.3)) + + abbrev@5.0.0: {} + + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + + acorn@8.19.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.2.1: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + chai@6.3.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + commander@14.0.3: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + copy-anything@4.1.5: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + cssesc@3.0.0: {} + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.3 + + csstype@3.2.3: {} + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + editorconfig@3.0.2: + dependencies: + '@one-ini/wasm': 0.2.1 + commander: 14.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + + entities@7.0.1: {} + + entities@8.1.0: {} + + es-module-lexer@2.3.2: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-vue@10.11.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@5.9.3))(eslint@10.12.0)(vue-eslint-parser@10.4.1(eslint@10.12.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + eslint: 10.12.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.6 + semver: 7.8.5 + vue-eslint-parser: 10.4.1(eslint@10.12.0) + xml-name-validator: 5.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.12.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hookable@5.5.3: {} + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.12: {} + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + js-beautify@2.0.3: + dependencies: + config-chain: 1.1.13 + editorconfig: 3.0.2 + glob: 13.0.6 + js-cookie: 3.0.8 + nopt: 10.0.1 + + js-cookie@3.0.8: {} + + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.32 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.16.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.22.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + marked@14.0.0: {} + + mdn-data@2.27.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minipass@7.1.3: {} + + mitt@3.0.1: {} + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.20: {} + + natural-compare@1.4.0: {} + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@3.0.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@7.1.1: {} + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pinia@3.0.4(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.43(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + postcss-selector-parser@7.1.6: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + remove-accents@0.5.0: {} + + require-from-string@2.0.2: {} + + rfdc@1.4.1: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.2: {} + + speakingurl@14.0.1: {} + + std-env@4.3.0: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.1.5 + + symbol-tree@3.2.4: {} + + tinybench@6.2.2: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.71.1(eslint@10.12.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@5.9.3))(eslint@10.12.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@5.9.3) + eslint: 10.12.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + upath@2.0.1: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-plugin-static-copy@4.1.1(vite@8.3.4(@types/node@24.19.2)): + dependencies: + chokidar: 3.6.0 + p-map: 7.1.1 + picocolors: 1.1.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@24.19.2) + + vite-plugin-vuetify@2.1.3(vite@8.3.4(@types/node@24.19.2))(vue@3.5.43(typescript@5.9.3))(vuetify@3.13.6): + dependencies: + '@vuetify/loader-shared': 2.1.2(vue@3.5.43(typescript@5.9.3))(vuetify@3.13.6) + debug: 4.4.3 + upath: 2.0.1 + vite: 8.3.4(@types/node@24.19.2) + vue: 3.5.43(typescript@5.9.3) + vuetify: 3.13.6(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.43(typescript@5.9.3)) + transitivePeerDependencies: + - supports-color + + vite@8.3.4(@types/node@24.19.2): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.2 + fsevents: 2.3.3 + + vitest@5.0.4(@types/node@24.19.2)(jsdom@27.4.0)(vite@8.3.4(@types/node@24.19.2)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.4(vite@8.3.4(@types/node@24.19.2)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.2 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@24.19.2) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.2 + jsdom: 27.4.0 + transitivePeerDependencies: + - msw + + vscode-uri@3.2.0: {} + + vue-component-type-helpers@3.3.13: {} + + vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)): + dependencies: + vue: 3.5.43(typescript@5.9.3) + + vue-eslint-parser@10.4.1(eslint@10.12.0): + dependencies: + debug: 4.4.3 + eslint: 10.12.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.43(typescript@5.9.3) + + vue-tsc@3.3.13(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.13 + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + vuetify@3.13.6(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.43(typescript@5.9.3)): + dependencies: + vue: 3.5.43(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + vite-plugin-vuetify: 2.1.3(vite@8.3.4(@types/node@24.19.2))(vue@3.5.43(typescript@5.9.3))(vuetify@3.13.6) + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + word-wrap@1.2.5: {} + + ws@8.22.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yocto-queue@0.1.0: {} diff --git a/frontend-vue/pnpm-workspace.yaml b/frontend-vue/pnpm-workspace.yaml new file mode 100644 index 000000000..44573a97b --- /dev/null +++ b/frontend-vue/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +# frontend-vue is installed on its own (like frontend/), not as a member of the root workspace. +packages: + - . + +# vue-demi's postinstall only switches between Vue 2/3 builds; the default Vue 3 build is what we need. +ignoredBuiltDependencies: + - vue-demi diff --git a/frontend-vue/public/config.json b/frontend-vue/public/config.json new file mode 100644 index 000000000..343eddd45 --- /dev/null +++ b/frontend-vue/public/config.json @@ -0,0 +1,5 @@ +{ + "#important to keep it /api for prod": true, + "baseURL": "/api", + "saasURL": "" +} diff --git a/frontend-vue/scripts/update-version.js b/frontend-vue/scripts/update-version.js new file mode 100644 index 000000000..e5f777df5 --- /dev/null +++ b/frontend-vue/scripts/update-version.js @@ -0,0 +1,16 @@ +// Port of frontend/scripts/update-version.js: writes the package.json version to src/version.ts. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const version = packageJson.version; + +const versionTsPath = path.join(root, 'src', 'version.ts'); +const versionTsContent = `// This file is auto-generated during build +export const version = '${version}'; +`; + +fs.writeFileSync(versionTsPath, versionTsContent); +console.log(`Updated version.ts with version ${version}`); diff --git a/frontend-vue/src/App.spec.ts b/frontend-vue/src/App.spec.ts new file mode 100644 index 000000000..e3c3e3098 --- /dev/null +++ b/frontend-vue/src/App.spec.ts @@ -0,0 +1,19 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { createVuetify } from 'vuetify'; +import App from './App.vue'; +import { routes } from './router'; + +describe('App', () => { + it('renders the home route', async () => { + const router = createRouter({ history: createMemoryHistory(), routes }); + router.push('/'); + await router.isReady(); + + const wrapper = mount(App, { global: { plugins: [router, createVuetify()] } }); + await flushPromises(); + + expect(wrapper.text()).toContain('Rocketadmin'); + }); +}); diff --git a/frontend-vue/src/App.vue b/frontend-vue/src/App.vue new file mode 100644 index 000000000..52f5b4537 --- /dev/null +++ b/frontend-vue/src/App.vue @@ -0,0 +1,5 @@ + diff --git a/frontend-vue/src/config.spec.ts b/frontend-vue/src/config.spec.ts new file mode 100644 index 000000000..4b81f2d35 --- /dev/null +++ b/frontend-vue/src/config.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defaultConfig, getConfig, loadConfig } from './config'; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('loadConfig', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reads baseURL and saasURL from the config file', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ baseURL: '/custom-api', saasURL: 'https://saas' })); + + const config = await loadConfig('/config.json', fetchFn); + + expect(fetchFn).toHaveBeenCalledWith('/config.json', { cache: 'no-cache' }); + expect(config).toEqual({ baseURL: '/custom-api', saasURL: 'https://saas' }); + expect(getConfig()).toEqual(config); + }); + + it('falls back to defaults for missing fields', async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ baseURL: '/custom-api' })); + + expect(await loadConfig('/config.json', fetchFn)).toEqual({ baseURL: '/custom-api', saasURL: '' }); + }); + + it('falls back to defaults when the request fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}, 404)); + + expect(await loadConfig('/config.json', fetchFn)).toEqual(defaultConfig); + expect(getConfig()).toEqual(defaultConfig); + }); +}); diff --git a/frontend-vue/src/config.ts b/frontend-vue/src/config.ts new file mode 100644 index 000000000..04154b726 --- /dev/null +++ b/frontend-vue/src/config.ts @@ -0,0 +1,34 @@ +export interface AppConfig { + baseURL: string; + saasURL: string; +} + +// Same values as public/config.json, used when the runtime file is missing or broken. +export const defaultConfig: AppConfig = { + baseURL: '/api', + saasURL: '', +}; + +let config: AppConfig = { ...defaultConfig }; + +// Port of ConfigModule.buildForConfigUrl: reads config.json before the app mounts, +// so the deployed file can be swapped without rebuilding the bundle. +export async function loadConfig(configUrl: string, fetchFn: typeof fetch = fetch): Promise { + try { + const response = await fetchFn(configUrl, { cache: 'no-cache' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const json = (await response.json()) as Partial; + config = { + baseURL: typeof json.baseURL === 'string' ? json.baseURL : defaultConfig.baseURL, + saasURL: typeof json.saasURL === 'string' ? json.saasURL : defaultConfig.saasURL, + }; + } catch (error) { + console.error(`Failed to load ${configUrl}, using defaults`, error); + config = { ...defaultConfig }; + } + return config; +} + +export function getConfig(): AppConfig { + return config; +} diff --git a/frontend-vue/src/env.d.ts b/frontend-vue/src/env.d.ts new file mode 100644 index 000000000..8a335ba07 --- /dev/null +++ b/frontend-vue/src/env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SAAS: string; + readonly VITE_API_ROOT: string; + readonly VITE_SAAS_URL: string; + readonly VITE_SAAS_HOSTNAMES: string; + readonly VITE_STAGING_HOST: string; + readonly VITE_TURNSTILE_SITE_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend-vue/src/environment.spec.ts b/frontend-vue/src/environment.spec.ts new file mode 100644 index 000000000..56853b62f --- /dev/null +++ b/frontend-vue/src/environment.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { buildEnvironment } from './environment'; +import { version } from './version'; + +describe('buildEnvironment', () => { + it('maps the SaaS production variables', () => { + expect( + buildEnvironment({ + PROD: true, + VITE_SAAS: 'true', + VITE_API_ROOT: '/api', + VITE_SAAS_URL: '', + VITE_SAAS_HOSTNAMES: 'app.rocketadmin.com, rocket.sitenova.com', + VITE_STAGING_HOST: 'staging.example.com', + VITE_TURNSTILE_SITE_KEY: 'site-key', + }), + ).toEqual({ + production: true, + saas: true, + apiRoot: '/api', + saasURL: '', + saasHostnames: ['app.rocketadmin.com', 'rocket.sitenova.com'], + stagingHost: 'staging.example.com', + version, + turnstileSiteKey: 'site-key', + }); + }); + + it('defaults to a self-hosted, non-production environment', () => { + const environment = buildEnvironment({ PROD: false, VITE_SAAS: 'false', VITE_SAAS_HOSTNAMES: '' }); + + expect(environment.production).toBe(false); + expect(environment.saas).toBe(false); + expect(environment.saasHostnames).toEqual([]); + expect(environment.apiRoot).toBe(''); + }); +}); diff --git a/frontend-vue/src/environment.ts b/frontend-vue/src/environment.ts new file mode 100644 index 000000000..a3af4f313 --- /dev/null +++ b/frontend-vue/src/environment.ts @@ -0,0 +1,31 @@ +import { version } from './version'; + +export interface Environment { + production: boolean; + saas: boolean; + apiRoot: string; + saasURL: string; + saasHostnames: string[]; + stagingHost: string; + version: string; + turnstileSiteKey: string; +} + +export function buildEnvironment(env: Record): Environment { + return { + production: env.PROD === true, + saas: env.VITE_SAAS === 'true', + apiRoot: String(env.VITE_API_ROOT ?? ''), + saasURL: String(env.VITE_SAAS_URL ?? ''), + saasHostnames: String(env.VITE_SAAS_HOSTNAMES ?? '') + .split(',') + .map((hostname) => hostname.trim()) + .filter(Boolean), + stagingHost: String(env.VITE_STAGING_HOST ?? ''), + version, + turnstileSiteKey: String(env.VITE_TURNSTILE_SITE_KEY ?? ''), + }; +} + +// Same shape as `environment` in frontend/src/environments/*.ts, built from the Vite mode's .env files. +export const environment: Environment = buildEnvironment(import.meta.env); diff --git a/frontend-vue/src/lib/cedar-wasm.ts b/frontend-vue/src/lib/cedar-wasm.ts new file mode 100644 index 000000000..7eecbe4a3 --- /dev/null +++ b/frontend-vue/src/lib/cedar-wasm.ts @@ -0,0 +1,19 @@ +// Port of CedarWasmService. The package's default init loads `cedar_wasm_bg.wasm` through +// `new URL(..., import.meta.url)`, which Vite emits as a hashed asset. +type CedarWasmModule = typeof import('@cedar-policy/cedar-wasm/web'); + +let loading: Promise | null = null; + +export function loadCedarWasm(): Promise { + if (!loading) { + loading = (async () => { + const cedar = await import('@cedar-policy/cedar-wasm/web'); + await cedar.default(); + return cedar; + })().catch((error) => { + loading = null; + throw error; + }); + } + return loading; +} diff --git a/frontend-vue/src/lib/monaco.ts b/frontend-vue/src/lib/monaco.ts new file mode 100644 index 000000000..b872aaf4f --- /dev/null +++ b/frontend-vue/src/lib/monaco.ts @@ -0,0 +1,40 @@ +// Monaco is bundled by Vite with its workers as `?worker` chunks, replacing the +// `assets/monaco` copy the Angular build serves to @ngstack/code-editor. +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +type Monaco = typeof import('monaco-editor'); + +export function getMonacoWorker(label: string): Worker { + switch (label) { + case 'json': + return new JsonWorker(); + case 'css': + case 'scss': + case 'less': + return new CssWorker(); + case 'html': + case 'handlebars': + case 'razor': + return new HtmlWorker(); + case 'typescript': + case 'javascript': + return new TsWorker(); + default: + return new EditorWorker(); + } +} + +let loading: Promise | null = null; + +// Monaco is large, so it is loaded on first use and kept in its own chunk. +export function loadMonaco(): Promise { + if (!loading) { + self.MonacoEnvironment = { getWorker: (_workerId, label) => getMonacoWorker(label) }; + loading = import('monaco-editor'); + } + return loading; +} diff --git a/frontend-vue/src/main.ts b/frontend-vue/src/main.ts new file mode 100644 index 000000000..24e05c9e5 --- /dev/null +++ b/frontend-vue/src/main.ts @@ -0,0 +1,22 @@ +import '@fontsource/noto-sans/300.css'; +import '@fontsource/noto-sans/400.css'; +import '@fontsource/noto-sans/500.css'; +import '@fontsource/ibm-plex-mono/400.css'; +import '@fontsource/ibm-plex-mono/500.css'; +import '@material-symbols/font-400/outlined.css'; + +import { VueQueryPlugin } from '@tanstack/vue-query'; +import { createPinia } from 'pinia'; +import { createApp } from 'vue'; +import App from './App.vue'; +import { loadConfig } from './config'; +import { vuetify } from './plugins/vuetify'; +import { router } from './router'; + +async function bootstrap() { + await loadConfig('/config.json'); + + createApp(App).use(createPinia()).use(router).use(vuetify).use(VueQueryPlugin).mount('#app'); +} + +bootstrap(); diff --git a/frontend-vue/src/plugins/vuetify.ts b/frontend-vue/src/plugins/vuetify.ts new file mode 100644 index 000000000..5fc02bf22 --- /dev/null +++ b/frontend-vue/src/plugins/vuetify.ts @@ -0,0 +1,9 @@ +import 'vuetify/styles'; +import { createVuetify } from 'vuetify'; + +// The palette-based theme (port of colorConfig in frontend/src/main.ts) is set up in Phase 1. +export const vuetify = createVuetify({ + theme: { + defaultTheme: 'system', + }, +}); diff --git a/frontend-vue/src/router/index.ts b/frontend-vue/src/router/index.ts new file mode 100644 index 000000000..e13775176 --- /dev/null +++ b/frontend-vue/src/router/index.ts @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; + +export const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + meta: { title: 'Rocketadmin' }, + }, +]; + +export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes, +}); diff --git a/frontend-vue/src/test-setup.ts b/frontend-vue/src/test-setup.ts new file mode 100644 index 000000000..a712a6ea4 --- /dev/null +++ b/frontend-vue/src/test-setup.ts @@ -0,0 +1,8 @@ +// jsdom does not implement ResizeObserver, which Vuetify layout components use. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +globalThis.ResizeObserver ??= ResizeObserverStub; diff --git a/frontend-vue/src/version.ts b/frontend-vue/src/version.ts new file mode 100644 index 000000000..fdb12fe4e --- /dev/null +++ b/frontend-vue/src/version.ts @@ -0,0 +1,2 @@ +// This file is auto-generated during build +export const version = '1.0.0'; diff --git a/frontend-vue/src/views/HomeView.vue b/frontend-vue/src/views/HomeView.vue new file mode 100644 index 000000000..cb5e7e2d0 --- /dev/null +++ b/frontend-vue/src/views/HomeView.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend-vue/tsconfig.app.json b/frontend-vue/tsconfig.app.json new file mode 100644 index 000000000..1af2e4361 --- /dev/null +++ b/frontend-vue/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + }, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend-vue/tsconfig.json b/frontend-vue/tsconfig.json new file mode 100644 index 000000000..88a659fb8 --- /dev/null +++ b/frontend-vue/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/frontend-vue/tsconfig.node.json b/frontend-vue/tsconfig.node.json new file mode 100644 index 000000000..02f42ecfd --- /dev/null +++ b/frontend-vue/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["es2023"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["vite.config.ts", "eslint.config.js", "scripts/**/*.js"] +} diff --git a/frontend-vue/vite.config.ts b/frontend-vue/vite.config.ts new file mode 100644 index 000000000..a678bc57b --- /dev/null +++ b/frontend-vue/vite.config.ts @@ -0,0 +1,67 @@ +/// +import fs from 'node:fs'; +import { fileURLToPath, URL } from 'node:url'; +import vue from '@vitejs/plugin-vue'; +import { defineConfig, type Plugin } from 'vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; +import vuetify from 'vite-plugin-vuetify'; + +const isSaasMode = (mode: string) => mode === 'saas' || mode === 'saas-production'; + +// SaaS builds serve index.saas.html (Intercom, gtag, GSI, Turnstile, loader) as index.html, +// like the `index` option of the Angular `saas` and `saas-production` configurations. +function saasIndexHtml(mode: string): Plugin { + return { + name: 'rocketadmin-saas-index-html', + transformIndexHtml: { + order: 'pre', + handler: (html) => + isSaasMode(mode) ? fs.readFileSync(fileURLToPath(new URL('./index.saas.html', import.meta.url)), 'utf8') : html, + }, + }; +} + +export default defineConfig(({ mode }) => ({ + plugins: [ + vue(), + vuetify({ autoImport: true }), + saasIndexHtml(mode), + viteStaticCopy({ + targets: [ + // Shared with the Angular app until the cutover (see VUE_MIGRATION_PLAN.md, Phase 4). + { src: '../frontend/src/assets', dest: '.', rename: { stripBase: 2 } }, + ], + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + host: '127.0.0.1', + // Port of frontend/src/proxy.conf.json + proxy: { + '/api': { + target: 'https://app.rocketadmin.com', + changeOrigin: true, + secure: false, + }, + }, + }, + worker: { + format: 'es', + }, + build: { + sourcemap: true, + }, + test: { + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], + server: { + deps: { + inline: ['vuetify'], + }, + }, + }, +})); From fe7046487c71faedf55c8c2de048ee463e47a4fe Mon Sep 17 00:00:00 2001 From: Lyubov Voloshko Date: Wed, 21 Oct 2026 12:55:04 +0000 Subject: [PATCH 02/22] Vue frontend phase 1: SiteNova design system and foundations Replace Vuetify with the design system copied from SiteNova: tokens (theme.css, base.css), the Ui* primitives with specs, stories and Foundations docs, the axe-core a11y gate, the design-token hygiene test and Storybook. New primitives: icon, icon button, badge, menu, drawer. Phase 1 foundations: - API client porting the token/url-params interceptors and api.service (base URL, credentials, GCLID/masterpwd, 401 redirect, error notifications, NDJSON streams) - Auth store with session handling, self-hosted store, meta-driven route guards and the full route table with the Angular paths - App shell: header, connection switcher and tabs, account menu, mobile drawer, view-as banner, feature notification, 404, snackbars; Sentry/PostHog/Amplitude setup - useDialog(), useNotify(), AppDialogForm, shared alert, banner, breadcrumbs, content loader, icon picker and the 17 skeletons - All 27 Angular services ported to Pinia stores and query composables, validators as pure functions; models, lib and consts copied over Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/frontend-vue.yml | 2 + VUE_MIGRATION_PLAN.md | 14 +- frontend-vue/.gitignore | 1 + frontend-vue/.storybook/decorators.ts | 49 + frontend-vue/.storybook/main.ts | 13 + frontend-vue/.storybook/preview.css | 11 + frontend-vue/.storybook/preview.ts | 79 + frontend-vue/README.md | 7 +- frontend-vue/biome.json | 2 +- frontend-vue/eslint.config.js | 2 + frontend-vue/index.html | 2 +- frontend-vue/index.saas.html | 2 +- frontend-vue/package.json | 34 +- frontend-vue/pnpm-lock.yaml | 2356 ++++++- frontend-vue/pnpm-workspace.yaml | 3 + frontend-vue/src/App.spec.ts | 16 +- frontend-vue/src/App.vue | 41 +- .../src/__tests__/design-tokens.spec.ts | 147 + frontend-vue/src/__tests__/mock-fetch.ts | 77 + frontend-vue/src/api/client.spec.ts | 219 + frontend-vue/src/api/client.ts | 300 + frontend-vue/src/api/query-options.ts | 7 + frontend-vue/src/assets/base.css | 89 + frontend-vue/src/assets/theme.css | 215 + .../src/components/app/AppHeader.spec.ts | 130 + frontend-vue/src/components/app/AppHeader.vue | 261 + .../src/components/app/AppNavDrawer.vue | 187 + .../src/components/app/AppSnackbars.spec.ts | 36 + .../src/components/app/AppSnackbars.vue | 91 + .../components/app/FeatureNotification.vue | 483 ++ .../src/components/app/ViewAsBanner.vue | 47 + frontend-vue/src/components/app/navigation.ts | 32 + .../src/components/dialog/AppDialog.vue | 45 + .../src/components/dialog/AppDialogForm.vue | 91 + .../src/components/dialog/DialogHost.vue | 11 + .../src/components/dialog/DialogOutlet.vue | 13 + .../src/components/dialog/dialog.spec.ts | 107 + .../MasterPasswordDialog.spec.ts | 78 + .../master-password/MasterPasswordDialog.vue | 42 + .../src/components/shared/AppAlert.spec.ts | 135 + .../src/components/shared/AppAlert.stories.ts | 78 + .../src/components/shared/AppAlert.vue | 166 + .../src/components/shared/AppBanner.spec.ts | 28 + .../components/shared/AppBanner.stories.ts | 49 + .../src/components/shared/AppBanner.vue | 98 + .../components/shared/AppBreadcrumbs.spec.ts | 54 + .../shared/AppBreadcrumbs.stories.ts | 35 + .../src/components/shared/AppBreadcrumbs.vue | 93 + .../shared/AppContentLoader.spec.ts | 16 + .../shared/AppContentLoader.stories.ts | 21 + .../components/shared/AppContentLoader.vue | 34 + .../components/shared/AppIconPicker.spec.ts | 118 + .../shared/AppIconPicker.stories.ts | 42 + .../src/components/shared/AppIconPicker.vue | 279 + .../skeletons/PlaceholderAddUserDialog.vue | 37 + .../skeletons/PlaceholderApiKeysList.vue | 35 + .../skeletons/PlaceholderAuditLog.vue | 12 + .../skeletons/PlaceholderCompany.vue | 55 + .../PlaceholderCompanyInvitation.vue | 53 + .../PlaceholderConnectionSettings.vue | 68 + .../skeletons/PlaceholderConnections.vue | 49 + .../skeletons/PlaceholderDashboards.vue | 58 + .../skeletons/PlaceholderRecordView.vue | 41 + .../skeletons/PlaceholderRowEdit.vue | 79 + .../skeletons/PlaceholderSavedFilters.vue | 29 + .../skeletons/PlaceholderTableData.vue | 93 + .../skeletons/PlaceholderTableSettings.vue | 121 + .../skeletons/PlaceholderTableView.vue | 58 + .../skeletons/PlaceholderTableWidgets.vue | 78 + .../skeletons/PlaceholderUserGroup.vue | 23 + .../skeletons/PlaceholderUserGroups.vue | 39 + .../skeletons/Placeholders.stories.ts | 85 + .../components/skeletons/placeholders.spec.ts | 90 + frontend-vue/src/components/ui/CONVENTIONS.md | 84 + .../src/components/ui/UiAlert.spec.ts | 43 + .../src/components/ui/UiAlert.stories.ts | 85 + frontend-vue/src/components/ui/UiAlert.vue | 78 + .../src/components/ui/UiBadge.spec.ts | 58 + .../src/components/ui/UiBadge.stories.ts | 48 + frontend-vue/src/components/ui/UiBadge.vue | 130 + .../src/components/ui/UiButton.spec.ts | 179 + .../src/components/ui/UiButton.stories.ts | 150 + frontend-vue/src/components/ui/UiButton.vue | 245 + frontend-vue/src/components/ui/UiCard.spec.ts | 34 + .../src/components/ui/UiCard.stories.ts | 100 + frontend-vue/src/components/ui/UiCard.vue | 92 + frontend-vue/src/components/ui/UiChip.spec.ts | 37 + .../src/components/ui/UiChip.stories.ts | 81 + frontend-vue/src/components/ui/UiChip.vue | 86 + .../src/components/ui/UiConfirmDialog.spec.ts | 101 + .../components/ui/UiConfirmDialog.stories.ts | 160 + .../src/components/ui/UiConfirmDialog.vue | 85 + .../src/components/ui/UiDrawer.spec.ts | 160 + .../src/components/ui/UiDrawer.stories.ts | 61 + frontend-vue/src/components/ui/UiDrawer.vue | 226 + .../src/components/ui/UiField.spec.ts | 170 + .../src/components/ui/UiField.stories.ts | 127 + frontend-vue/src/components/ui/UiField.vue | 107 + frontend-vue/src/components/ui/UiIcon.spec.ts | 39 + .../src/components/ui/UiIcon.stories.ts | 42 + frontend-vue/src/components/ui/UiIcon.vue | 82 + .../src/components/ui/UiIconButton.spec.ts | 103 + .../src/components/ui/UiIconButton.stories.ts | 66 + .../src/components/ui/UiIconButton.vue | 200 + frontend-vue/src/components/ui/UiInput.vue | 126 + frontend-vue/src/components/ui/UiMenu.spec.ts | 260 + .../src/components/ui/UiMenu.stories.ts | 98 + frontend-vue/src/components/ui/UiMenu.vue | 270 + frontend-vue/src/components/ui/UiMenuItem.vue | 179 + .../src/components/ui/UiModal.spec.ts | 167 + .../src/components/ui/UiModal.stories.ts | 139 + frontend-vue/src/components/ui/UiModal.vue | 234 + .../src/components/ui/UiPageShell.spec.ts | 45 + .../src/components/ui/UiPageShell.stories.ts | 100 + .../src/components/ui/UiPageShell.vue | 81 + .../src/components/ui/UiProgressBar.spec.ts | 75 + .../components/ui/UiProgressBar.stories.ts | 96 + .../src/components/ui/UiProgressBar.vue | 88 + .../src/components/ui/UiSkeleton.spec.ts | 79 + .../src/components/ui/UiSkeleton.stories.ts | 83 + frontend-vue/src/components/ui/UiSkeleton.vue | 126 + .../src/components/ui/UiSpinner.spec.ts | 28 + .../src/components/ui/UiSpinner.stories.ts | 62 + frontend-vue/src/components/ui/UiSpinner.vue | 92 + frontend-vue/src/components/ui/UiTab.vue | 113 + frontend-vue/src/components/ui/UiTabPanel.vue | 41 + frontend-vue/src/components/ui/UiTabs.spec.ts | 158 + .../src/components/ui/UiTabs.stories.ts | 127 + frontend-vue/src/components/ui/UiTabs.vue | 111 + .../src/components/ui/__tests__/a11y.spec.ts | 420 ++ .../src/components/ui/__tests__/axe.ts | 111 + .../components/ui/docs/00-GettingStarted.mdx | 67 + .../src/components/ui/docs/01-Conventions.mdx | 13 + .../src/components/ui/docs/02-Color.mdx | 77 + .../src/components/ui/docs/03-Typography.mdx | 61 + .../src/components/ui/docs/04-Spacing.mdx | 49 + .../src/components/ui/docs/05-Shape.mdx | 35 + .../src/components/ui/docs/06-Elevation.mdx | 56 + .../src/components/ui/docs/07-Motion.mdx | 85 + .../src/components/ui/docs/ScaleSpecimens.vue | 101 + .../src/components/ui/docs/SpecimenList.vue | 88 + .../src/components/ui/docs/SwatchGrid.vue | 103 + .../src/components/ui/docs/TokenTable.vue | 110 + .../src/components/ui/docs/TypeSpecimens.vue | 70 + .../components/ui/docs/conventions.spec.ts | 56 + .../components/ui/docs/foundations.stories.ts | 125 + .../src/components/ui/docs/readTokens.spec.ts | 60 + .../src/components/ui/docs/readTokens.ts | 65 + .../src/components/ui/docs/specimens.spec.ts | 159 + .../src/components/ui/fieldContext.ts | 19 + frontend-vue/src/components/ui/index.ts | 32 + frontend-vue/src/components/ui/menuContext.ts | 14 + frontend-vue/src/components/ui/tabsContext.ts | 25 + frontend-vue/src/composables/useAnalytics.ts | 12 + .../src/composables/useAppSession.spec.ts | 142 + frontend-vue/src/composables/useAppSession.ts | 88 + .../src/composables/useCedarValidator.spec.ts | 52 + .../src/composables/useCedarValidator.ts | 29 + frontend-vue/src/composables/useDialog.ts | 87 + frontend-vue/src/composables/useNotify.ts | 11 + frontend-vue/src/consts/acronyms.ts | 5828 +++++++++++++++++ frontend-vue/src/consts/code-snippets.ts | 211 + frontend-vue/src/consts/countries.ts | 266 + frontend-vue/src/consts/currencies.ts | 87 + frontend-vue/src/consts/databases.ts | 25 + .../src/consts/google-IP-addresses.ts | 530 ++ frontend-vue/src/consts/languages.ts | 201 + frontend-vue/src/lib/ai-suggestions.spec.ts | 165 + frontend-vue/src/lib/ai-suggestions.ts | 290 + frontend-vue/src/lib/binary.spec.ts | 180 + frontend-vue/src/lib/binary.ts | 119 + frontend-vue/src/lib/cedar-monaco-language.ts | 49 + frontend-vue/src/lib/cedar-policy-items.ts | 132 + frontend-vue/src/lib/cedar-policy-parser.ts | 330 + .../src/lib/cedar-policy-roundtrip.spec.ts | 561 ++ frontend-vue/src/lib/chart-config.helper.ts | 512 ++ frontend-vue/src/lib/connection-theme.spec.ts | 28 + frontend-vue/src/lib/connection-theme.ts | 40 + frontend-vue/src/lib/format-field-value.ts | 31 + frontend-vue/src/lib/monitoring.ts | 40 + frontend-vue/src/lib/normalize.spec.ts | 24 + frontend-vue/src/lib/normalize.ts | 20 + .../src/lib/parse-filter-params.spec.ts | 36 + frontend-vue/src/lib/parse-filter-params.ts | 17 + .../src/lib/permission-display.spec.ts | 81 + frontend-vue/src/lib/permission-display.ts | 80 + .../src/lib/setup-table-row-structure.spec.ts | 96 + .../src/lib/setup-table-row-structure.ts | 18 + frontend-vue/src/lib/table-events.spec.ts | 26 + frontend-vue/src/lib/table-events.ts | 48 + frontend-vue/src/main.ts | 51 +- frontend-vue/src/models/ai-suggestions.ts | 87 + frontend-vue/src/models/alert.ts | 31 + frontend-vue/src/models/company.ts | 69 + frontend-vue/src/models/connection.ts | 76 + frontend-vue/src/models/dashboard.ts | 45 + frontend-vue/src/models/logs.ts | 24 + frontend-vue/src/models/saved-query.ts | 124 + frontend-vue/src/models/secret.ts | 61 + frontend-vue/src/models/table.ts | 157 + frontend-vue/src/models/ui-settings.ts | 25 + frontend-vue/src/models/user.ts | 107 + frontend-vue/src/plugins/vuetify.ts | 9 - frontend-vue/src/router/guards.spec.ts | 86 + frontend-vue/src/router/guards.ts | 69 + frontend-vue/src/router/index.ts | 16 +- frontend-vue/src/router/routes.ts | 69 + .../src/stores/ai-suggestions.spec.ts | 39 + frontend-vue/src/stores/ai-suggestions.ts | 47 + frontend-vue/src/stores/ai.spec.ts | 97 + frontend-vue/src/stores/ai.ts | 78 + frontend-vue/src/stores/auth.spec.ts | 100 + frontend-vue/src/stores/auth.ts | 215 + .../src/stores/cedar-permissions.spec.ts | 452 ++ frontend-vue/src/stores/cedar-permissions.ts | 172 + frontend-vue/src/stores/company.spec.ts | 240 + frontend-vue/src/stores/company.ts | 236 + .../src/stores/configuration-state.spec.ts | 86 + .../src/stores/configuration-state.ts | 147 + frontend-vue/src/stores/connections.spec.ts | 513 ++ frontend-vue/src/stores/connections.ts | 424 ++ frontend-vue/src/stores/dashboards.spec.ts | 241 + frontend-vue/src/stores/dashboards.ts | 160 + .../src/stores/master-password.spec.ts | 48 + frontend-vue/src/stores/master-password.ts | 46 + frontend-vue/src/stores/notifications.spec.ts | 49 + frontend-vue/src/stores/notifications.ts | 79 + frontend-vue/src/stores/s3.spec.ts | 135 + frontend-vue/src/stores/s3.ts | 120 + frontend-vue/src/stores/saved-queries.spec.ts | 156 + frontend-vue/src/stores/saved-queries.ts | 98 + frontend-vue/src/stores/secrets.spec.ts | 223 + frontend-vue/src/stores/secrets.ts | 145 + frontend-vue/src/stores/selfhosted.ts | 69 + frontend-vue/src/stores/table-row.spec.ts | 94 + frontend-vue/src/stores/table-row.ts | 66 + frontend-vue/src/stores/table-schema.spec.ts | 57 + frontend-vue/src/stores/table-schema.ts | 111 + frontend-vue/src/stores/table-state.spec.ts | 82 + frontend-vue/src/stores/table-state.ts | 114 + frontend-vue/src/stores/tables.spec.ts | 369 ++ frontend-vue/src/stores/tables.ts | 405 ++ frontend-vue/src/stores/ui-settings.spec.ts | 100 + frontend-vue/src/stores/ui-settings.ts | 112 + frontend-vue/src/stores/user.spec.ts | 229 + frontend-vue/src/stores/user.ts | 187 + frontend-vue/src/stores/users.spec.ts | 343 + frontend-vue/src/stores/users.ts | 284 + frontend-vue/src/stores/view-as.spec.ts | 105 + frontend-vue/src/stores/view-as.ts | 68 + frontend-vue/src/test-setup.ts | 17 +- frontend-vue/src/types/globals.d.ts | 10 + .../src/validators/base64.validator.spec.ts | 19 + .../src/validators/base64.validator.ts | 17 + .../connection-string.validator.spec.ts | 66 + .../validators/connection-string.validator.ts | 148 + .../src/validators/email.validator.spec.ts | 24 + .../src/validators/email.validator.ts | 17 + .../src/validators/hex.validator.spec.ts | 18 + frontend-vue/src/validators/hex.validator.ts | 17 + .../src/validators/hostname.validator.spec.ts | 48 + .../src/validators/hostname.validator.ts | 55 + .../src/validators/password.validator.spec.ts | 30 + .../src/validators/password.validator.ts | 45 + .../src/validators/text.validator.spec.ts | 48 + frontend-vue/src/validators/text.validator.ts | 80 + frontend-vue/src/validators/types.ts | 18 + .../src/validators/url.validator.spec.ts | 23 + frontend-vue/src/validators/url.validator.ts | 25 + frontend-vue/src/views/HomeView.vue | 10 - frontend-vue/src/views/NotFoundView.vue | 66 + frontend-vue/src/views/PendingView.vue | 34 + frontend-vue/tsconfig.app.json | 3 +- frontend-vue/vite.config.ts | 12 +- 274 files changed, 35358 insertions(+), 160 deletions(-) create mode 100644 frontend-vue/.storybook/decorators.ts create mode 100644 frontend-vue/.storybook/main.ts create mode 100644 frontend-vue/.storybook/preview.css create mode 100644 frontend-vue/.storybook/preview.ts create mode 100644 frontend-vue/src/__tests__/design-tokens.spec.ts create mode 100644 frontend-vue/src/__tests__/mock-fetch.ts create mode 100644 frontend-vue/src/api/client.spec.ts create mode 100644 frontend-vue/src/api/client.ts create mode 100644 frontend-vue/src/api/query-options.ts create mode 100644 frontend-vue/src/assets/base.css create mode 100644 frontend-vue/src/assets/theme.css create mode 100644 frontend-vue/src/components/app/AppHeader.spec.ts create mode 100644 frontend-vue/src/components/app/AppHeader.vue create mode 100644 frontend-vue/src/components/app/AppNavDrawer.vue create mode 100644 frontend-vue/src/components/app/AppSnackbars.spec.ts create mode 100644 frontend-vue/src/components/app/AppSnackbars.vue create mode 100644 frontend-vue/src/components/app/FeatureNotification.vue create mode 100644 frontend-vue/src/components/app/ViewAsBanner.vue create mode 100644 frontend-vue/src/components/app/navigation.ts create mode 100644 frontend-vue/src/components/dialog/AppDialog.vue create mode 100644 frontend-vue/src/components/dialog/AppDialogForm.vue create mode 100644 frontend-vue/src/components/dialog/DialogHost.vue create mode 100644 frontend-vue/src/components/dialog/DialogOutlet.vue create mode 100644 frontend-vue/src/components/dialog/dialog.spec.ts create mode 100644 frontend-vue/src/components/master-password/MasterPasswordDialog.spec.ts create mode 100644 frontend-vue/src/components/master-password/MasterPasswordDialog.vue create mode 100644 frontend-vue/src/components/shared/AppAlert.spec.ts create mode 100644 frontend-vue/src/components/shared/AppAlert.stories.ts create mode 100644 frontend-vue/src/components/shared/AppAlert.vue create mode 100644 frontend-vue/src/components/shared/AppBanner.spec.ts create mode 100644 frontend-vue/src/components/shared/AppBanner.stories.ts create mode 100644 frontend-vue/src/components/shared/AppBanner.vue create mode 100644 frontend-vue/src/components/shared/AppBreadcrumbs.spec.ts create mode 100644 frontend-vue/src/components/shared/AppBreadcrumbs.stories.ts create mode 100644 frontend-vue/src/components/shared/AppBreadcrumbs.vue create mode 100644 frontend-vue/src/components/shared/AppContentLoader.spec.ts create mode 100644 frontend-vue/src/components/shared/AppContentLoader.stories.ts create mode 100644 frontend-vue/src/components/shared/AppContentLoader.vue create mode 100644 frontend-vue/src/components/shared/AppIconPicker.spec.ts create mode 100644 frontend-vue/src/components/shared/AppIconPicker.stories.ts create mode 100644 frontend-vue/src/components/shared/AppIconPicker.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderAddUserDialog.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderApiKeysList.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderAuditLog.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderCompany.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderCompanyInvitation.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderConnectionSettings.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderConnections.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderDashboards.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderRecordView.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderRowEdit.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderSavedFilters.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderTableData.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderTableSettings.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderTableView.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderTableWidgets.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderUserGroup.vue create mode 100644 frontend-vue/src/components/skeletons/PlaceholderUserGroups.vue create mode 100644 frontend-vue/src/components/skeletons/Placeholders.stories.ts create mode 100644 frontend-vue/src/components/skeletons/placeholders.spec.ts create mode 100644 frontend-vue/src/components/ui/CONVENTIONS.md create mode 100644 frontend-vue/src/components/ui/UiAlert.spec.ts create mode 100644 frontend-vue/src/components/ui/UiAlert.stories.ts create mode 100644 frontend-vue/src/components/ui/UiAlert.vue create mode 100644 frontend-vue/src/components/ui/UiBadge.spec.ts create mode 100644 frontend-vue/src/components/ui/UiBadge.stories.ts create mode 100644 frontend-vue/src/components/ui/UiBadge.vue create mode 100644 frontend-vue/src/components/ui/UiButton.spec.ts create mode 100644 frontend-vue/src/components/ui/UiButton.stories.ts create mode 100644 frontend-vue/src/components/ui/UiButton.vue create mode 100644 frontend-vue/src/components/ui/UiCard.spec.ts create mode 100644 frontend-vue/src/components/ui/UiCard.stories.ts create mode 100644 frontend-vue/src/components/ui/UiCard.vue create mode 100644 frontend-vue/src/components/ui/UiChip.spec.ts create mode 100644 frontend-vue/src/components/ui/UiChip.stories.ts create mode 100644 frontend-vue/src/components/ui/UiChip.vue create mode 100644 frontend-vue/src/components/ui/UiConfirmDialog.spec.ts create mode 100644 frontend-vue/src/components/ui/UiConfirmDialog.stories.ts create mode 100644 frontend-vue/src/components/ui/UiConfirmDialog.vue create mode 100644 frontend-vue/src/components/ui/UiDrawer.spec.ts create mode 100644 frontend-vue/src/components/ui/UiDrawer.stories.ts create mode 100644 frontend-vue/src/components/ui/UiDrawer.vue create mode 100644 frontend-vue/src/components/ui/UiField.spec.ts create mode 100644 frontend-vue/src/components/ui/UiField.stories.ts create mode 100644 frontend-vue/src/components/ui/UiField.vue create mode 100644 frontend-vue/src/components/ui/UiIcon.spec.ts create mode 100644 frontend-vue/src/components/ui/UiIcon.stories.ts create mode 100644 frontend-vue/src/components/ui/UiIcon.vue create mode 100644 frontend-vue/src/components/ui/UiIconButton.spec.ts create mode 100644 frontend-vue/src/components/ui/UiIconButton.stories.ts create mode 100644 frontend-vue/src/components/ui/UiIconButton.vue create mode 100644 frontend-vue/src/components/ui/UiInput.vue create mode 100644 frontend-vue/src/components/ui/UiMenu.spec.ts create mode 100644 frontend-vue/src/components/ui/UiMenu.stories.ts create mode 100644 frontend-vue/src/components/ui/UiMenu.vue create mode 100644 frontend-vue/src/components/ui/UiMenuItem.vue create mode 100644 frontend-vue/src/components/ui/UiModal.spec.ts create mode 100644 frontend-vue/src/components/ui/UiModal.stories.ts create mode 100644 frontend-vue/src/components/ui/UiModal.vue create mode 100644 frontend-vue/src/components/ui/UiPageShell.spec.ts create mode 100644 frontend-vue/src/components/ui/UiPageShell.stories.ts create mode 100644 frontend-vue/src/components/ui/UiPageShell.vue create mode 100644 frontend-vue/src/components/ui/UiProgressBar.spec.ts create mode 100644 frontend-vue/src/components/ui/UiProgressBar.stories.ts create mode 100644 frontend-vue/src/components/ui/UiProgressBar.vue create mode 100644 frontend-vue/src/components/ui/UiSkeleton.spec.ts create mode 100644 frontend-vue/src/components/ui/UiSkeleton.stories.ts create mode 100644 frontend-vue/src/components/ui/UiSkeleton.vue create mode 100644 frontend-vue/src/components/ui/UiSpinner.spec.ts create mode 100644 frontend-vue/src/components/ui/UiSpinner.stories.ts create mode 100644 frontend-vue/src/components/ui/UiSpinner.vue create mode 100644 frontend-vue/src/components/ui/UiTab.vue create mode 100644 frontend-vue/src/components/ui/UiTabPanel.vue create mode 100644 frontend-vue/src/components/ui/UiTabs.spec.ts create mode 100644 frontend-vue/src/components/ui/UiTabs.stories.ts create mode 100644 frontend-vue/src/components/ui/UiTabs.vue create mode 100644 frontend-vue/src/components/ui/__tests__/a11y.spec.ts create mode 100644 frontend-vue/src/components/ui/__tests__/axe.ts create mode 100644 frontend-vue/src/components/ui/docs/00-GettingStarted.mdx create mode 100644 frontend-vue/src/components/ui/docs/01-Conventions.mdx create mode 100644 frontend-vue/src/components/ui/docs/02-Color.mdx create mode 100644 frontend-vue/src/components/ui/docs/03-Typography.mdx create mode 100644 frontend-vue/src/components/ui/docs/04-Spacing.mdx create mode 100644 frontend-vue/src/components/ui/docs/05-Shape.mdx create mode 100644 frontend-vue/src/components/ui/docs/06-Elevation.mdx create mode 100644 frontend-vue/src/components/ui/docs/07-Motion.mdx create mode 100644 frontend-vue/src/components/ui/docs/ScaleSpecimens.vue create mode 100644 frontend-vue/src/components/ui/docs/SpecimenList.vue create mode 100644 frontend-vue/src/components/ui/docs/SwatchGrid.vue create mode 100644 frontend-vue/src/components/ui/docs/TokenTable.vue create mode 100644 frontend-vue/src/components/ui/docs/TypeSpecimens.vue create mode 100644 frontend-vue/src/components/ui/docs/conventions.spec.ts create mode 100644 frontend-vue/src/components/ui/docs/foundations.stories.ts create mode 100644 frontend-vue/src/components/ui/docs/readTokens.spec.ts create mode 100644 frontend-vue/src/components/ui/docs/readTokens.ts create mode 100644 frontend-vue/src/components/ui/docs/specimens.spec.ts create mode 100644 frontend-vue/src/components/ui/fieldContext.ts create mode 100644 frontend-vue/src/components/ui/index.ts create mode 100644 frontend-vue/src/components/ui/menuContext.ts create mode 100644 frontend-vue/src/components/ui/tabsContext.ts create mode 100644 frontend-vue/src/composables/useAnalytics.ts create mode 100644 frontend-vue/src/composables/useAppSession.spec.ts create mode 100644 frontend-vue/src/composables/useAppSession.ts create mode 100644 frontend-vue/src/composables/useCedarValidator.spec.ts create mode 100644 frontend-vue/src/composables/useCedarValidator.ts create mode 100644 frontend-vue/src/composables/useDialog.ts create mode 100644 frontend-vue/src/composables/useNotify.ts create mode 100644 frontend-vue/src/consts/acronyms.ts create mode 100644 frontend-vue/src/consts/code-snippets.ts create mode 100644 frontend-vue/src/consts/countries.ts create mode 100644 frontend-vue/src/consts/currencies.ts create mode 100644 frontend-vue/src/consts/databases.ts create mode 100644 frontend-vue/src/consts/google-IP-addresses.ts create mode 100644 frontend-vue/src/consts/languages.ts create mode 100644 frontend-vue/src/lib/ai-suggestions.spec.ts create mode 100644 frontend-vue/src/lib/ai-suggestions.ts create mode 100644 frontend-vue/src/lib/binary.spec.ts create mode 100644 frontend-vue/src/lib/binary.ts create mode 100644 frontend-vue/src/lib/cedar-monaco-language.ts create mode 100644 frontend-vue/src/lib/cedar-policy-items.ts create mode 100644 frontend-vue/src/lib/cedar-policy-parser.ts create mode 100644 frontend-vue/src/lib/cedar-policy-roundtrip.spec.ts create mode 100644 frontend-vue/src/lib/chart-config.helper.ts create mode 100644 frontend-vue/src/lib/connection-theme.spec.ts create mode 100644 frontend-vue/src/lib/connection-theme.ts create mode 100644 frontend-vue/src/lib/format-field-value.ts create mode 100644 frontend-vue/src/lib/monitoring.ts create mode 100644 frontend-vue/src/lib/normalize.spec.ts create mode 100644 frontend-vue/src/lib/normalize.ts create mode 100644 frontend-vue/src/lib/parse-filter-params.spec.ts create mode 100644 frontend-vue/src/lib/parse-filter-params.ts create mode 100644 frontend-vue/src/lib/permission-display.spec.ts create mode 100644 frontend-vue/src/lib/permission-display.ts create mode 100644 frontend-vue/src/lib/setup-table-row-structure.spec.ts create mode 100644 frontend-vue/src/lib/setup-table-row-structure.ts create mode 100644 frontend-vue/src/lib/table-events.spec.ts create mode 100644 frontend-vue/src/lib/table-events.ts create mode 100644 frontend-vue/src/models/ai-suggestions.ts create mode 100644 frontend-vue/src/models/alert.ts create mode 100644 frontend-vue/src/models/company.ts create mode 100644 frontend-vue/src/models/connection.ts create mode 100644 frontend-vue/src/models/dashboard.ts create mode 100644 frontend-vue/src/models/logs.ts create mode 100644 frontend-vue/src/models/saved-query.ts create mode 100644 frontend-vue/src/models/secret.ts create mode 100644 frontend-vue/src/models/table.ts create mode 100644 frontend-vue/src/models/ui-settings.ts create mode 100644 frontend-vue/src/models/user.ts delete mode 100644 frontend-vue/src/plugins/vuetify.ts create mode 100644 frontend-vue/src/router/guards.spec.ts create mode 100644 frontend-vue/src/router/guards.ts create mode 100644 frontend-vue/src/router/routes.ts create mode 100644 frontend-vue/src/stores/ai-suggestions.spec.ts create mode 100644 frontend-vue/src/stores/ai-suggestions.ts create mode 100644 frontend-vue/src/stores/ai.spec.ts create mode 100644 frontend-vue/src/stores/ai.ts create mode 100644 frontend-vue/src/stores/auth.spec.ts create mode 100644 frontend-vue/src/stores/auth.ts create mode 100644 frontend-vue/src/stores/cedar-permissions.spec.ts create mode 100644 frontend-vue/src/stores/cedar-permissions.ts create mode 100644 frontend-vue/src/stores/company.spec.ts create mode 100644 frontend-vue/src/stores/company.ts create mode 100644 frontend-vue/src/stores/configuration-state.spec.ts create mode 100644 frontend-vue/src/stores/configuration-state.ts create mode 100644 frontend-vue/src/stores/connections.spec.ts create mode 100644 frontend-vue/src/stores/connections.ts create mode 100644 frontend-vue/src/stores/dashboards.spec.ts create mode 100644 frontend-vue/src/stores/dashboards.ts create mode 100644 frontend-vue/src/stores/master-password.spec.ts create mode 100644 frontend-vue/src/stores/master-password.ts create mode 100644 frontend-vue/src/stores/notifications.spec.ts create mode 100644 frontend-vue/src/stores/notifications.ts create mode 100644 frontend-vue/src/stores/s3.spec.ts create mode 100644 frontend-vue/src/stores/s3.ts create mode 100644 frontend-vue/src/stores/saved-queries.spec.ts create mode 100644 frontend-vue/src/stores/saved-queries.ts create mode 100644 frontend-vue/src/stores/secrets.spec.ts create mode 100644 frontend-vue/src/stores/secrets.ts create mode 100644 frontend-vue/src/stores/selfhosted.ts create mode 100644 frontend-vue/src/stores/table-row.spec.ts create mode 100644 frontend-vue/src/stores/table-row.ts create mode 100644 frontend-vue/src/stores/table-schema.spec.ts create mode 100644 frontend-vue/src/stores/table-schema.ts create mode 100644 frontend-vue/src/stores/table-state.spec.ts create mode 100644 frontend-vue/src/stores/table-state.ts create mode 100644 frontend-vue/src/stores/tables.spec.ts create mode 100644 frontend-vue/src/stores/tables.ts create mode 100644 frontend-vue/src/stores/ui-settings.spec.ts create mode 100644 frontend-vue/src/stores/ui-settings.ts create mode 100644 frontend-vue/src/stores/user.spec.ts create mode 100644 frontend-vue/src/stores/user.ts create mode 100644 frontend-vue/src/stores/users.spec.ts create mode 100644 frontend-vue/src/stores/users.ts create mode 100644 frontend-vue/src/stores/view-as.spec.ts create mode 100644 frontend-vue/src/stores/view-as.ts create mode 100644 frontend-vue/src/types/globals.d.ts create mode 100644 frontend-vue/src/validators/base64.validator.spec.ts create mode 100644 frontend-vue/src/validators/base64.validator.ts create mode 100644 frontend-vue/src/validators/connection-string.validator.spec.ts create mode 100644 frontend-vue/src/validators/connection-string.validator.ts create mode 100644 frontend-vue/src/validators/email.validator.spec.ts create mode 100644 frontend-vue/src/validators/email.validator.ts create mode 100644 frontend-vue/src/validators/hex.validator.spec.ts create mode 100644 frontend-vue/src/validators/hex.validator.ts create mode 100644 frontend-vue/src/validators/hostname.validator.spec.ts create mode 100644 frontend-vue/src/validators/hostname.validator.ts create mode 100644 frontend-vue/src/validators/password.validator.spec.ts create mode 100644 frontend-vue/src/validators/password.validator.ts create mode 100644 frontend-vue/src/validators/text.validator.spec.ts create mode 100644 frontend-vue/src/validators/text.validator.ts create mode 100644 frontend-vue/src/validators/types.ts create mode 100644 frontend-vue/src/validators/url.validator.spec.ts create mode 100644 frontend-vue/src/validators/url.validator.ts delete mode 100644 frontend-vue/src/views/HomeView.vue create mode 100644 frontend-vue/src/views/NotFoundView.vue create mode 100644 frontend-vue/src/views/PendingView.vue diff --git a/.github/workflows/frontend-vue.yml b/.github/workflows/frontend-vue.yml index 5801d6b3c..82705c01f 100644 --- a/.github/workflows/frontend-vue.yml +++ b/.github/workflows/frontend-vue.yml @@ -51,3 +51,5 @@ jobs: run: pnpm build - name: Build (SaaS) run: pnpm build:saas + - name: Build Storybook + run: pnpm build-storybook diff --git a/VUE_MIGRATION_PLAN.md b/VUE_MIGRATION_PLAN.md index 92d9655e5..99d5dc900 100644 --- a/VUE_MIGRATION_PLAN.md +++ b/VUE_MIGRATION_PLAN.md @@ -31,9 +31,9 @@ Why I'm not recommending a gradual, route-by-route hybrid: the app shell, sidena | Routing | Angular Router, `loadComponent`, guards | **vue-router 4**: lazy `() => import()` routes and `beforeEach` guards based on route `meta` | | State | BehaviorSubject and signal services | **Pinia** stores (setup syntax). Signals map to `ref`/`computed`; `rxResource`/`httpResource` map to **TanStack Vue Query** | | HTTP | HttpClient, interceptors, `api.service.ts` (fetch) | One `fetch` wrapper (`src/api/client.ts`) that ports the interceptor logic and the NDJSON streaming | -| UI kit | Angular Material and CDK | **Vuetify 3** (Material Design, so its components match `mat-*` most closely) | -| Theming | ngx-theme runtime palettes, `--color-*` variables | Generate the same `--color--` variables from the same `colorConfig` (ported to `src/theme/palettes.ts`), and map them into the Vuetify theme. Dark mode follows `prefers-color-scheme` | -| Forms | `ngModel` plus validator directives | `v-model`, Vuetify `:rules`, and the validators ported as pure functions in `src/validators/` | +| UI kit | Angular Material and CDK | **The SiteNova design system**, copied into `src/components/ui/`: a flat set of `Ui*` primitives (button, field, input, modal, menu, drawer, tabs, alert, chip, skeleton, …) on native elements, no component library. Rules in `src/components/ui/CONVENTIONS.md`, docs in Storybook | +| Theming | ngx-theme runtime palettes, `--color-*` variables | SiteNova's tokens (`src/assets/theme.css`), verbatim: a raw `--palette-*` layer kept in sync with SiteNova, and semantic `--color-*` / `--space-*` / `--text-*` aliases that components use. Per-connection custom accent colours override `--color-accent*` at runtime. Light only for now, like SiteNova (dark mode is a follow-up) | +| Forms | `ngModel` plus validator directives | `v-model` on `UiField`/`UiInput`, and the validators ported as pure functions in `src/validators/` | | Dynamic widgets | `ndc-dynamic` | `` | | Code editor | `@ngstack/code-editor` | **`@guolao/vue-monaco-editor`**, or a thin wrapper around `monaco-editor`; the Cedar language in `lib/cedar-monaco-language.ts` is reused as is | | Charts | ng2-charts | **vue-chartjs**; `lib/chart-config.helper.ts` is reused | @@ -59,7 +59,7 @@ Copy these as-is and add tests where they are missing: ## Phases ### Phase 0 – Scaffolding and infrastructure -1. Create `frontend-vue/` with Vite, Vue 3, TypeScript, Pinia, vue-router, Vuetify, TanStack Query and Vitest. +1. Create `frontend-vue/` with Vite, Vue 3, TypeScript, Pinia, vue-router, TanStack Query and Vitest. (Phase 0 started with Vuetify; Phase 1 replaced it with the SiteNova design system.) 2. **Environments:** use Vite modes `development`, `production`, `saas` and `saas-production`, with `.env.*` files that hold the same fields (`VITE_SAAS`, `VITE_API_ROOT`, `VITE_SAAS_URL`, `VITE_SAAS_HOSTNAMES`, `VITE_STAGING_HOST`, `VITE_TURNSTILE_SITE_KEY`). Choose the SaaS `index.html` with a small Vite plugin (`transformIndexHtml`), or with two HTML entry points. 3. **Runtime `config.json` loader:** port `ConfigModule.buildForConfigUrl` so `baseURL` is read before the app mounts. 4. **Dev proxy:** port `src/proxy.conf.json` to `server.proxy` in Vite (`/api` → `https://app.rocketadmin.com/api/`). @@ -77,7 +77,7 @@ Copy these as-is and add tests where they are missing: - Show errors through the notifications store. - Support NDJSON streaming. 2. **Auth and guards:** an `auth` store, plus router guards (`requiresAuth`, `guestOnly`, `configuration`, `setup`) that reproduce the `token_expiration` check and the self-hosted setup redirect. -3. **Theme:** port the `colorConfig` from `src/main.ts` into a palette generator that writes the same `--color-*` CSS variables. Port the global `custom-theme.scss` and `styles.scss`. Recreate the `--mat-*` overrides as Vuetify defaults and variables. +3. **Theme and design system:** copy SiteNova's design system (`sitenova/frontend`): `theme.css`, `base.css`, the `Ui*` primitives with their specs, stories and Foundations docs, the axe-core a11y gate and the token-hygiene test (`src/__tests__/design-tokens.spec.ts`), and Storybook. Drop the SiteNova-only parts (vue-i18n, the site preview). This replaces `colorConfig`, `custom-theme.scss` and the `--mat-*` overrides. 4. **App shell:** port `app.component` (toolbar, sidenav, user menu, banners), route titles (`meta.title` through `afterEach`), the 404 page, and Sentry/analytics setup (SaaS mode only). 5. **Shared UI primitives** that replace Material patterns used everywhere: - **`useDialog()`:** a programmatic dialog service that keeps the `dialog.open(Component, { data })` → `afterClosed()` promise shape. This keeps the 47 dialogs mechanical to port. @@ -98,7 +98,7 @@ Copy these as-is and add tests where they are missing: 1. **Auth flows:** login (with Google GSI and Turnstile), setup, forget-password, password-reset/change, email verify/change, company invitation, deleted account. 2. **Connections:** `connections-list`, `connect-db` (16 components), `edit-db`, `connection-settings`, `auto-configure`. 3. **Table browser** (`components/dashboard/`, the hardest part): - - `db-tables-list` (630 lines) and `db-table-view` (1,103 + 752 lines). Use Vuetify `v-data-table-server` for paging, sorting and selection, and sync the query state with the URL (replaces `ngmat-table-query-reflector`). + - `db-tables-list` (630 lines) and `db-table-view` (1,103 + 752 lines). Build a `UiTable` primitive (server-side paging, sorting and selection), and sync the query state with the URL (replaces `ngmat-table-query-reflector`). - Filters dialog, saved-filters panel, export/import, row view/edit (`db-table-row-edit`, 871 lines), and the actions, widgets and settings pages. - The AI panel, with NDJSON streaming and markdown/mermaid rendering. 4. **Users and permissions:** groups, permissions, the Cedar policy editor (Monaco plus wasm), view-as. @@ -120,7 +120,7 @@ Copy these as-is and add tests where they are missing: - **Tests:** port the useful specs, focused on behavior (stores, API client, guards, validators, widgets), with `@vue/test-utils`, using `Partial` mocks and no `as any`. ## Risks -- **Matching Material's look in Vuetify:** form-field density and the dialog and snackbar look will differ slightly. Settle the look in Phase 1 against Storybook before porting 250 components. +- **The look changes on purpose:** the Vue app uses the SiteNova look, not Material, so the parity review compares behaviour and information, not pixels. Missing primitives (table, select, autocomplete, date picker, …) have to be built in the design system before the pages that need them; settle each one in Storybook first. - **`db-table-view` and `db-table-row-edit`** hold the most logic. Pull their logic into composables first, and test that logic before porting the templates. - **The string-based event bus** in legacy services can hide coupling between components. Map every consumer before replacing it. - **Monaco workers and the Cedar wasm under Vite** must be checked early, in Phase 0. diff --git a/frontend-vue/.gitignore b/frontend-vue/.gitignore index 4f82fbf0a..a3fa27212 100644 --- a/frontend-vue/.gitignore +++ b/frontend-vue/.gitignore @@ -4,3 +4,4 @@ coverage *.tsbuildinfo .env.local .env.*.local +storybook-static diff --git a/frontend-vue/.storybook/decorators.ts b/frontend-vue/.storybook/decorators.ts new file mode 100644 index 000000000..1f384b54e --- /dev/null +++ b/frontend-vue/.storybook/decorators.ts @@ -0,0 +1,49 @@ +import type { RouteRecordRaw } from 'vue-router'; + +/** + * Storybook parameters this project understands, set per story/meta and consumed by the global + * `setup()` hook in `./preview.ts`. Plugins have to be installed on the app *before* it mounts, + * which a decorator can't do — but `setup()` receives the story context, so the story declares + * what it needs as data here and preview.ts does the installing. + */ + +/** Which route is current + any extra paths to register. See `preview.ts`. */ +export interface RouterParams { + /** Route the story starts on, so active-link styling can be exercised. Defaults to '/'. */ + initialPath?: string; + /** Route paths to register beyond the app's top-level ones. */ + extraPaths?: string[]; +} + +/** Session state seeded before the component mounts. */ +export interface AuthParams { + /** ISO timestamp the session expires at; `null` signs out. */ + expiresAt?: string | null; +} + +/** A date far enough out that stories don't start failing once it passes. */ +export const FUTURE_EXPIRY = '2999-01-01T00:00:00.000Z'; + +export const signedIn: AuthParams = { expiresAt: FUTURE_EXPIRY }; +export const signedOut: AuthParams = { expiresAt: null }; + +/** + * Route table for a story's router. Stubbed to blank components — a story only needs its `to` + * targets to resolve so `router-link-active` classes apply; it never navigates to the real views. + * The real `src/router` can't be reused because its guards redirect signed-out visitors to /login. + */ +export function buildRoutes(params: RouterParams = {}): RouteRecordRaw[] { + const blank = { template: '
' }; + const paths = new Set([ + '/', + '/connections-list', + '/user-settings', + '/company', + '/secrets', + '/zapier', + '/login', + params.initialPath ?? '/', + ...(params.extraPaths ?? []), + ]); + return [...paths].map((path) => ({ path, component: blank })); +} diff --git a/frontend-vue/.storybook/main.ts b/frontend-vue/.storybook/main.ts new file mode 100644 index 000000000..f218c41d1 --- /dev/null +++ b/frontend-vue/.storybook/main.ts @@ -0,0 +1,13 @@ +import type { StorybookConfig } from '@storybook/vue3-vite'; + +const config: StorybookConfig = { + // Stories live next to the component they document. + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-docs', '@storybook/addon-a11y'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, +}; + +export default config; diff --git a/frontend-vue/.storybook/preview.css b/frontend-vue/.storybook/preview.css new file mode 100644 index 000000000..8b398bac7 --- /dev/null +++ b/frontend-vue/.storybook/preview.css @@ -0,0 +1,11 @@ +/** + * Storybook-only overrides. Everything else comes from src/assets/base.css, which the app loads + * too — this file holds just the handful of rules that must differ in a story canvas. + */ + +body { + /* The backgrounds toolbar owns the canvas colour so components can be checked on paper, surface + and ink. base.css hard-sets --color-paper for the real app, which would pin every story to + paper and hide contrast bugs. */ + background: transparent; +} diff --git a/frontend-vue/.storybook/preview.ts b/frontend-vue/.storybook/preview.ts new file mode 100644 index 000000000..7e3c5bd4d --- /dev/null +++ b/frontend-vue/.storybook/preview.ts @@ -0,0 +1,79 @@ +// Same import order as src/main.ts: fonts first so their @font-face rules land before the theme, +// then the tokens every component's scoped CSS reads, then the global element rules. base.css is +// the same file the app loads, so a story renders on the app's foundation. +import '@fontsource-variable/geist'; +import '@fontsource-variable/space-grotesk'; +import '@fontsource-variable/geist-mono'; +import '@material-symbols/font-400/outlined.css'; +import '../src/assets/theme.css'; +import '../src/assets/base.css'; +import './preview.css'; + +import type { Preview } from '@storybook/vue3-vite'; +import { setup } from '@storybook/vue3-vite'; +import { createPinia } from 'pinia'; +import { createMemoryHistory, createRouter } from 'vue-router'; +import { type AuthParams, buildRoutes, type RouterParams } from './decorators'; + +/** + * Installs a fresh Pinia + router on every story's app. This runs before mount, which is why it + * lives here rather than in a decorator: components calling a store or useRouter() at setup time + * would throw otherwise, and a per-story instance means state can't leak between stories. + */ +setup((app, context) => { + const pinia = createPinia(); + app.use(pinia); + + const routerParams = (context?.parameters?.router ?? {}) as RouterParams; + // Seeded through the constructor rather than `push`, which resolves a tick later — long enough + // to render the wrong `router-link-exact-active` link. + const history = createMemoryHistory(); + history.replace(routerParams.initialPath ?? '/'); + app.use(createRouter({ history, routes: buildRoutes(routerParams) })); + + // Signs the story in or out through the same localStorage key the auth store reads. + const auth = (context?.parameters?.auth ?? {}) as AuthParams; + if (auth.expiresAt === null) localStorage.removeItem('token_expiration'); + else if (auth.expiresAt) localStorage.setItem('token_expiration', auth.expiresAt); +}); + +const preview: Preview = { + parameters: { + options: { + // Foundations (tokens) before UI (primitives) before App, because that's the order you read the + // design system in. The sidebar sorts on `title`, not filename, so the sub-order is explicit. + storySort: { + order: [ + 'Foundations', + ['Getting Started', 'Conventions', 'Color', 'Typography', 'Spacing', 'Shape', 'Elevation', 'Motion'], + 'UI', + 'App', + ], + }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // Fail rather than warn, globally: an a11y violation in a primitive multiplies across every + // view that consumes it. Foundations' specimen stories opt out with `a11y: { disable: true }`. + test: 'error', + }, + backgrounds: { + options: { + // The app renders on --color-paper, not white — a white default would hide contrast bugs. + paper: { name: 'Paper', value: '#f5f8fe' }, + surface: { name: 'Surface', value: '#ffffff' }, + ink: { name: 'Ink', value: '#0a0f24' }, + }, + }, + }, + initialGlobals: { + backgrounds: { value: 'paper' }, + }, +}; + +export default preview; diff --git a/frontend-vue/README.md b/frontend-vue/README.md index 6ca073e11..b4faed580 100644 --- a/frontend-vue/README.md +++ b/frontend-vue/README.md @@ -2,7 +2,7 @@ Vue 3 rewrite of the RocketAdmin frontend (see `../VUE_MIGRATION_PLAN.md`). The Angular app in `../frontend` stays in production until this one reaches parity. -Stack: Vite, Vue 3 (` diff --git a/frontend-vue/index.saas.html b/frontend-vue/index.saas.html index af05899e3..8c2ee6913 100644 --- a/frontend-vue/index.saas.html +++ b/frontend-vue/index.saas.html @@ -142,7 +142,7 @@ - +
- - - @if (showResults()) { -
-
-

Chart preview

- @if (executionTime() !== null) { - - Executed in {{ executionTime() }}ms - - } -
- - @if (hasChartData()) { -
- - -
- } - - @if (!hasChartData() && testResults().length > 0) { -
-

Add at least one data series to display the chart

-
- } -
- } - - @if (showResults() && testResults().length === 0) { -
- info -

Query executed successfully but returned no results.

-
- } - -
-
-
- tune - Manual configuration -
- {{ manualExpanded() ? 'expand_less' : 'expand_more' }} -
- - @if (manualExpanded()) { -
-
- - Query name - - - - - Description (optional) - - -
- -
-
-
-

SQL query

- -
-
- - -
-
- - @if (showResults()) { -
-
-
-

Chart configuration

-
- - -
- - Chart type - - @for (type of chartTypes; track type.value) { - {{ type.label }} - } - - - - - Label column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - - - @if (showLabelTypeOption()) { - - Label type - - @for (type of labelTypes; track type.value) { - {{ type.label }} - } - - - } -
- - -
-
-

Data series

- @if (!isPieType()) { - - Series mode - - Manual - Series from column - - - } -
- - @if (seriesMode() === 'column' && !isPieType()) { -
- - Series column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Categorical column to split into datasets - - - - Value column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - Numeric column to chart - -
- } - - @if (seriesMode() === 'manual' || isPieType()) { - @if (!isPieType() || seriesList().length === 0) { - - } - - @for (series of seriesList(); track $index; let i = $index) { -
-
- Series {{ i + 1 }} - @if (seriesList().length > 1) { - - } -
- -
- - Value column - - @for (col of resultColumns(); track col) { - {{ col }} - } - - - - - Label - - - - @if (!isPieType()) { -
- Color -
- - @if (series.color) { - - } - @if (!series.color) { - Auto - } -
-
- } - - @if (!isPieType() && chartType() !== 'bar') { - - Point style - - @for (ps of pointStyles; track ps.value) { - {{ ps.label }} - } - - - } -
- - @if (!isPieType()) { -
- @if (chartType() === 'line' || series.type === 'line') { - - Fill area - - } - - @if (chartType() === 'line' || series.type === 'line') { - - Tension - - - } - - @if (seriesList().length > 1) { - - Type override - - Default - Bar - Line - - - } -
- } -
- } - } -
-
- - - - - - - Display options - - -
- @if (!isPieType()) { - - Stacked - - } - - @if (chartType() === 'bar') { - - Horizontal - - } - - - Show data labels - - - - Show legend - - - @if (legendShow()) { - - Legend position - - @for (pos of legendPositions; track pos.value) { - {{ pos.label }} - } - - - } -
-
- - - - - Units & formatting - - -
- - Unit mode - - @for (mode of unitModes; track mode.value) { - {{ mode.label }} - } - - - - @if (unitMode() === 'custom') { -
- - Unit text - - - - - Unit position - - Prefix ($100) - Suffix (100ms) - - -
- } - - @if (unitMode() === 'convert') { - - Source unit - - @for (group of convertUnitPresets; track group.group) { - - @for (unit of group.units; track unit.value) { - {{ unit.label }} - } - - } - - Values auto-convert to the best readable unit - - } - - @if (unitMode() !== 'convert') { -
- - Decimal places - - -
- - - Thousands separator - - - - Compact notation (1K, 1M, 1B) - - } -
-
- - - @if (!isPieType()) { - - - Axis configuration - - -
-

Y-Axis

-
- - Title - - - - - Scale type - - @for (st of scaleTypes; track st.value) { - {{ st.label }} - } - - -
- -
- - Min - - - - - Max - - -
- - - Begin at zero - - -

X-Axis

- - Title - - -
-
- } - - - - - Data options - - -
-
- - Sort by - - @for (opt of sortOptions; track opt.value) { - {{ opt.label }} - } - - - - - Limit - - -
-
-
- - - - - Custom color palette - - -
-
- @for (color of colorPalette(); track $index; let i = $index) { -
- - -
- } -
-
- - @if (colorPalette().length === 0) { - - } -
-
-
-
-
- } -
- - @if (showResults() && testResults().length > 0) { -
-
-

Query results ({{ testResults().length }} rows)

-
-
- - @for (column of resultColumns(); track column) { - - - - - } - - - -
{{ column }}{{ row[column] }}
-
-
- } -
- } -
- - } - - - @if (!loading()) { -
- Back - -
- } - - diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts deleted file mode 100644 index 994f01bfa..000000000 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NO_ERRORS_SCHEMA, Signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ChartSeriesConfig, ChartType, SavedQuery } from 'src/app/models/saved-query'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { ChartEditComponent } from './chart-edit.component'; - -type ChartEditComponentTestable = ChartEditComponent & { - isEditMode: WritableSignal; - queryName: WritableSignal; - queryText: WritableSignal; - queryDescription: WritableSignal; - saving: WritableSignal; - testing: WritableSignal; - testResults: WritableSignal[]>; - resultColumns: WritableSignal; - executionTime: WritableSignal; - labelColumn: WritableSignal; - seriesList: WritableSignal; - chartType: WritableSignal; - canSave: Signal; - canTest: Signal; - hasChartData: Signal; -}; - -describe('ChartEditComponent', () => { - let component: ChartEditComponent; - let fixture: ComponentFixture; - let mockSavedQueriesService: Partial; - let mockConnectionsService: Partial; - let mockUiSettingsService: Partial; - let router: Router; - - const mockSavedQuery: SavedQuery = { - id: '1', - name: 'Test Query', - description: 'Test description', - widget_type: 'chart', - chart_type: 'bar', - widget_options: { label_column: 'name', value_column: 'count' }, - query_text: 'SELECT * FROM users', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }; - - beforeEach(async () => { - mockSavedQueriesService = { - fetchSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), - createSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), - updateSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), - testQuery: vi.fn().mockResolvedValue({ - data: [{ name: 'John', count: 10 }], - execution_time_ms: 50, - }), - }; - - mockConnectionsService = { - getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), - }; - - mockUiSettingsService = { - isDarkMode: true, - getUiSettings: vi.fn().mockReturnValue(of({})), - }; - - await TestBed.configureTestingModule({ - imports: [ - ChartEditComponent, - BrowserAnimationsModule, - MatSnackBarModule, - RouterTestingModule, - Angulartics2Module.forRoot(), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: UiSettingsService, useValue: mockUiSettingsService }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - paramMap: { - get: (key: string) => { - if (key === 'connection-id') return 'conn-1'; - if (key === 'query-id') return null; - return null; - }, - }, - }, - }, - }, - ], - }) - .overrideComponent(ChartEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - router = TestBed.inject(Router); - vi.spyOn(router, 'navigate'); - - fixture = TestBed.createComponent(ChartEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize in create mode when no query-id', () => { - const testable = component as ChartEditComponentTestable; - expect(testable.isEditMode()).toBe(false); - }); - - it('should set page title on init', () => { - expect(mockConnectionsService.getCurrentConnectionTitle).toHaveBeenCalled(); - }); - - it('should have correct default chart type', () => { - const testable = component as ChartEditComponentTestable; - expect(testable.chartType()).toBe('bar'); - }); - - describe('canSave computed', () => { - it('should return false when name is empty', () => { - const testable = component as ChartEditComponentTestable; - testable.queryName.set(''); - testable.queryText.set('SELECT 1'); - expect(testable.canSave()).toBe(false); - }); - - it('should return false when query is empty', () => { - const testable = component as ChartEditComponentTestable; - testable.queryName.set('Test'); - testable.queryText.set(''); - expect(testable.canSave()).toBe(false); - }); - - it('should return true when name and query are provided', () => { - const testable = component as ChartEditComponentTestable; - testable.queryName.set('Test'); - testable.queryText.set('SELECT 1'); - testable.saving.set(false); - expect(testable.canSave()).toBe(true); - }); - - it('should return false when saving', () => { - const testable = component as ChartEditComponentTestable; - testable.queryName.set('Test'); - testable.queryText.set('SELECT 1'); - testable.saving.set(true); - expect(testable.canSave()).toBe(false); - }); - }); - - describe('canTest computed', () => { - it('should return false when query is empty', () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set(''); - expect(testable.canTest()).toBe(false); - }); - - it('should return true when query is provided', () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set('SELECT 1'); - testable.testing.set(false); - expect(testable.canTest()).toBe(true); - }); - - it('should return false when testing', () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set('SELECT 1'); - testable.testing.set(true); - expect(testable.canTest()).toBe(false); - }); - }); - - describe('testQuery', () => { - it('should call testQuery service method', async () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set('SELECT * FROM users'); - await component.testQuery(); - expect(mockSavedQueriesService.testQuery).toHaveBeenCalledWith('conn-1', { - query_text: 'SELECT * FROM users', - }); - }); - - it('should set results and columns after successful test', async () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set('SELECT * FROM users'); - await component.testQuery(); - expect(testable.testResults()).toEqual([{ name: 'John', count: 10 }]); - expect(testable.resultColumns()).toEqual(['name', 'count']); - expect(testable.executionTime()).toBe(50); - }); - - it('should auto-select label column and first series', async () => { - const testable = component as ChartEditComponentTestable; - testable.queryText.set('SELECT * FROM users'); - await component.testQuery(); - expect(testable.labelColumn()).toBe('name'); - expect(testable.seriesList()).toEqual([{ value_column: 'count' }]); - }); - }); - - describe('saveQuery', () => { - it('should call createSavedQuery in create mode', async () => { - const testable = component as ChartEditComponentTestable; - testable.isEditMode.set(false); - testable.queryName.set('New Query'); - testable.queryText.set('SELECT 1'); - await component.saveQuery(); - expect(mockSavedQueriesService.createSavedQuery).toHaveBeenCalledWith('conn-1', { - name: 'New Query', - description: undefined, - query_text: 'SELECT 1', - widget_type: 'chart', - chart_type: 'bar', - widget_options: { label_column: '', label_type: 'values' }, - }); - }); - - it('should navigate to charts list after save', async () => { - const testable = component as ChartEditComponentTestable; - testable.queryName.set('New Query'); - testable.queryText.set('SELECT 1'); - await component.saveQuery(); - expect(router.navigate).toHaveBeenCalledWith(['/panels', 'conn-1']); - }); - }); - - describe('onCodeChange', () => { - it('should update queryText', () => { - const testable = component as ChartEditComponentTestable; - component.onCodeChange('SELECT * FROM table'); - expect(testable.queryText()).toBe('SELECT * FROM table'); - }); - }); - - describe('hasChartData computed', () => { - it('should return false when no results', () => { - const testable = component as ChartEditComponentTestable; - testable.testResults.set([]); - expect(testable.hasChartData()).toBe(false); - }); - - it('should return false when no series configured', () => { - const testable = component as ChartEditComponentTestable; - testable.testResults.set([{ name: 'John' }]); - testable.labelColumn.set('name'); - testable.seriesList.set([]); - expect(testable.hasChartData()).toBe(false); - }); - - it('should return true when results, label and series are set', () => { - const testable = component as ChartEditComponentTestable; - testable.testResults.set([{ name: 'John', count: 10 }]); - testable.labelColumn.set('name'); - testable.seriesList.set([{ value_column: 'count' }]); - expect(testable.hasChartData()).toBe(true); - }); - }); -}); diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts deleted file mode 100644 index f31a4ecde..000000000 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts +++ /dev/null @@ -1,716 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; -import { - ChartAxisConfig, - ChartLegendConfig, - ChartNumberFormatConfig, - ChartSeriesConfig, - ChartType, - ChartUnitConfig, - ChartWidgetOptions, - GeneratedPanelWithPosition, - TestQueryResult, -} from 'src/app/models/saved-query'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { DashboardsSidebarComponent } from '../../dashboards/dashboards-sidebar/dashboards-sidebar.component'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { ChartPreviewComponent } from '../chart-preview/chart-preview.component'; - -@Component({ - selector: 'app-chart-edit', - templateUrl: './chart-edit.component.html', - styleUrls: ['./chart-edit.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatButtonModule, - MatCheckboxModule, - MatExpansionModule, - MatIconModule, - MatInputModule, - MatFormFieldModule, - MatSelectModule, - MatTableModule, - MatTooltipModule, - MatProgressSpinnerModule, - CodeEditorModule, - ChartPreviewComponent, - AlertComponent, - DashboardsSidebarComponent, - ], -}) -export class ChartEditComponent implements OnInit { - protected connectionId = signal(''); - protected queryId = signal(''); - protected isEditMode = signal(false); - protected loading = signal(true); - protected saving = signal(false); - protected testing = signal(false); - - protected queryName = signal(''); - protected queryDescription = signal(''); - protected queryText = signal(''); - - protected testResults = signal[]>([]); - protected resultColumns = signal([]); - protected executionTime = signal(null); - protected showResults = signal(false); - - // Basic chart config - protected chartType = signal('bar'); - protected labelColumn = signal(''); - protected labelType = signal<'values' | 'datetime'>('values'); - - // Series config - protected seriesMode = signal<'manual' | 'column'>('manual'); - protected seriesList = signal([]); - protected seriesColumn = signal(''); - protected seriesValueColumn = signal(''); - - // Display options - protected stacked = signal(false); - protected horizontal = signal(false); - protected showDataLabels = signal(false); - - // Legend - protected legendShow = signal(true); - protected legendPosition = signal<'top' | 'bottom' | 'left' | 'right'>('top'); - - // Units - protected unitMode = signal<'none' | 'custom' | 'convert'>('none'); - protected unitsText = signal(''); - protected unitsPosition = signal<'prefix' | 'suffix'>('suffix'); - protected convertUnit = signal(''); - - // Number format - protected decimalPlaces = signal(null); - protected thousandsSeparator = signal(true); - protected compact = signal(false); - - // Y-axis - protected yAxisTitle = signal(''); - protected yAxisMin = signal(null); - protected yAxisMax = signal(null); - protected yAxisBeginAtZero = signal(true); - protected yAxisScaleType = signal<'linear' | 'logarithmic'>('linear'); - - // X-axis - protected xAxisTitle = signal(''); - - // Sort & limit - protected sortBy = signal<'label_asc' | 'label_desc' | 'value_asc' | 'value_desc' | 'none'>('none'); - protected limit = signal(null); - - // Color palette - protected colorPalette = signal([]); - - // AI generation - protected aiDescription = signal(''); - protected aiGenerating = signal(false); - protected aiExpanded = signal(true); - protected manualExpanded = signal(false); - protected canGenerate = computed(() => !!this.aiDescription().trim() && !this.aiGenerating()); - - public chartTypes: { value: ChartType; label: string }[] = [ - { value: 'bar', label: 'Bar Chart' }, - { value: 'line', label: 'Line Chart' }, - { value: 'pie', label: 'Pie Chart' }, - { value: 'doughnut', label: 'Doughnut Chart' }, - { value: 'polarArea', label: 'Polar Area Chart' }, - ]; - - public labelTypes: { value: 'values' | 'datetime'; label: string }[] = [ - { value: 'values', label: 'Values' }, - { value: 'datetime', label: 'Datetime' }, - ]; - - public legendPositions: { value: string; label: string }[] = [ - { value: 'top', label: 'Top' }, - { value: 'bottom', label: 'Bottom' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, - ]; - - public sortOptions: { value: string; label: string }[] = [ - { value: 'none', label: 'None' }, - { value: 'label_asc', label: 'Label (A-Z)' }, - { value: 'label_desc', label: 'Label (Z-A)' }, - { value: 'value_asc', label: 'Value (Low-High)' }, - { value: 'value_desc', label: 'Value (High-Low)' }, - ]; - - public scaleTypes: { value: string; label: string }[] = [ - { value: 'linear', label: 'Linear' }, - { value: 'logarithmic', label: 'Logarithmic' }, - ]; - - public pointStyles: { value: string; label: string }[] = [ - { value: 'circle', label: 'Circle' }, - { value: 'rect', label: 'Rectangle' }, - { value: 'triangle', label: 'Triangle' }, - { value: 'cross', label: 'Cross' }, - { value: 'none', label: 'None' }, - ]; - - public unitModes: { value: string; label: string }[] = [ - { value: 'none', label: 'None' }, - { value: 'custom', label: 'Custom text' }, - { value: 'convert', label: 'Auto-convert' }, - ]; - - public convertUnitPresets: { group: string; units: { value: string; label: string }[] }[] = [ - { - group: 'Time', - units: [ - { value: 'ms', label: 'Milliseconds (ms)' }, - { value: 's', label: 'Seconds (s)' }, - { value: 'min', label: 'Minutes (min)' }, - { value: 'h', label: 'Hours (h)' }, - { value: 'd', label: 'Days (d)' }, - ], - }, - { - group: 'Data', - units: [ - { value: 'B', label: 'Bytes (B)' }, - { value: 'KB', label: 'Kilobytes (KB)' }, - { value: 'MB', label: 'Megabytes (MB)' }, - { value: 'GB', label: 'Gigabytes (GB)' }, - { value: 'TB', label: 'Terabytes (TB)' }, - ], - }, - { - group: 'Length', - units: [ - { value: 'mm', label: 'Millimeters (mm)' }, - { value: 'cm', label: 'Centimeters (cm)' }, - { value: 'm', label: 'Meters (m)' }, - { value: 'km', label: 'Kilometers (km)' }, - { value: 'in', label: 'Inches (in)' }, - { value: 'ft', label: 'Feet (ft)' }, - { value: 'mi', label: 'Miles (mi)' }, - ], - }, - { - group: 'Mass', - units: [ - { value: 'mg', label: 'Milligrams (mg)' }, - { value: 'g', label: 'Grams (g)' }, - { value: 'kg', label: 'Kilograms (kg)' }, - { value: 'oz', label: 'Ounces (oz)' }, - { value: 'lb', label: 'Pounds (lb)' }, - ], - }, - { - group: 'Temperature', - units: [ - { value: 'C', label: 'Celsius (C)' }, - { value: 'F', label: 'Fahrenheit (F)' }, - { value: 'K', label: 'Kelvin (K)' }, - ], - }, - { - group: 'Frequency', - units: [ - { value: 'Hz', label: 'Hertz (Hz)' }, - { value: 'kHz', label: 'Kilohertz (kHz)' }, - { value: 'MHz', label: 'Megahertz (MHz)' }, - { value: 'GHz', label: 'Gigahertz (GHz)' }, - ], - }, - { - group: 'Power', - units: [ - { value: 'W', label: 'Watts (W)' }, - { value: 'kW', label: 'Kilowatts (kW)' }, - { value: 'MW', label: 'Megawatts (MW)' }, - ], - }, - { - group: 'Energy', - units: [ - { value: 'J', label: 'Joules (J)' }, - { value: 'Wh', label: 'Watt-hours (Wh)' }, - { value: 'kWh', label: 'Kilowatt-hours (kWh)' }, - ], - }, - { - group: 'Pressure', - units: [ - { value: 'Pa', label: 'Pascals (Pa)' }, - { value: 'bar', label: 'Bar' }, - { value: 'psi', label: 'PSI' }, - { value: 'atm', label: 'Atmospheres (atm)' }, - ], - }, - { - group: 'Volume', - units: [ - { value: 'mL', label: 'Milliliters (mL)' }, - { value: 'L', label: 'Liters (L)' }, - { value: 'gal', label: 'Gallons (gal)' }, - ], - }, - ]; - - protected showLabelTypeOption = computed(() => ['bar', 'line'].includes(this.chartType())); - protected isPieType = computed(() => ['pie', 'doughnut', 'polarArea'].includes(this.chartType())); - - protected codeModel = signal({ - language: 'sql', - uri: 'query.sql', - value: '', - }); - - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on' as const, - lineNumbers: 'on' as const, - }; - public codeEditorTheme = 'vs-dark'; - - protected canSave = computed(() => !!this.queryName().trim() && !!this.queryText().trim() && !this.saving()); - protected canTest = computed(() => !!this.queryText().trim() && !this.testing()); - - protected hasChartData = computed(() => { - const hasLabel = !!this.labelColumn(); - if (this.seriesMode() === 'column') { - return this.testResults().length > 0 && hasLabel && !!this.seriesColumn() && !!this.seriesValueColumn(); - } - const hasSeries = this.seriesList().length > 0 && this.seriesList().some((s) => !!s.value_column); - return this.testResults().length > 0 && hasLabel && hasSeries; - }); - - protected currentWidgetOptions = computed(() => { - const options: ChartWidgetOptions = { - label_column: this.labelColumn(), - label_type: this.labelType(), - }; - - if (this.seriesMode() === 'column' && this.seriesColumn()) { - options.series_column = this.seriesColumn(); - options.value_column = this.seriesValueColumn(); - } else { - const series = this.seriesList(); - if (series.length > 0) { - options.series = series; - } - } - - if (this.stacked()) options.stacked = true; - if (this.horizontal()) options.horizontal = true; - if (this.showDataLabels()) options.show_data_labels = true; - - if (!this.legendShow() || this.legendPosition() !== 'top') { - options.legend = { - show: this.legendShow(), - position: this.legendPosition(), - }; - } - - if (this.unitMode() === 'custom' && this.unitsText()) { - options.units = { text: this.unitsText(), position: this.unitsPosition() }; - } else if (this.unitMode() === 'convert' && this.convertUnit()) { - options.units = { convert_unit: this.convertUnit() }; - } - - const numberFormat: ChartNumberFormatConfig = {}; - if (this.decimalPlaces() !== null) numberFormat.decimal_places = this.decimalPlaces()!; - if (!this.thousandsSeparator()) numberFormat.thousands_separator = false; - if (this.compact()) numberFormat.compact = true; - if (Object.keys(numberFormat).length > 0) options.number_format = numberFormat; - - const yAxis: ChartAxisConfig = {}; - if (this.yAxisTitle()) yAxis.title = this.yAxisTitle(); - if (this.yAxisMin() !== null) yAxis.min = this.yAxisMin()!; - if (this.yAxisMax() !== null) yAxis.max = this.yAxisMax()!; - if (!this.yAxisBeginAtZero()) yAxis.begin_at_zero = false; - if (this.yAxisScaleType() !== 'linear') yAxis.scale_type = this.yAxisScaleType(); - if (Object.keys(yAxis).length > 0) options.y_axis = yAxis; - - const xAxis: ChartAxisConfig = {}; - if (this.xAxisTitle()) xAxis.title = this.xAxisTitle(); - if (Object.keys(xAxis).length > 0) options.x_axis = xAxis; - - if (this.sortBy() !== 'none') options.sort_by = this.sortBy(); - if (this.limit() !== null && this.limit()! > 0) options.limit = this.limit()!; - - const palette = this.colorPalette(); - if (palette.length > 0) options.color_palette = palette; - - return options; - }); - - private _savedQueries = inject(SavedQueriesService); - private _connections = inject(ConnectionsService); - private _dashboards = inject(DashboardsService); - private _uiSettings = inject(UiSettingsService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private angulartics2 = inject(Angulartics2); - private title = inject(Title); - - private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' }); - - constructor() { - effect(() => { - const title = this.connectionTitle(); - const pageTitle = this.isEditMode() ? 'Edit Query' : 'Create Query'; - this.title.setTitle(`${pageTitle} | ${title || 'Rocketadmin'}`); - }); - } - - ngOnInit(): void { - this.connectionId.set(this.route.snapshot.paramMap.get('connection-id') || ''); - this.queryId.set(this.route.snapshot.paramMap.get('query-id') || ''); - this.isEditMode.set(!!this.queryId()); - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - - if (this.isEditMode()) { - this.manualExpanded.set(true); - this.aiExpanded.set(false); - this.loadSavedQuery(); - } else { - this.loading.set(false); - } - - this._loadAiPrerequisites(); - } - - async loadSavedQuery(): Promise { - this.loading.set(true); - try { - const query = await this._savedQueries.fetchSavedQuery(this.connectionId(), this.queryId()); - - if (query) { - this.queryName.set(query.name); - this.queryDescription.set(query.description || ''); - this.queryText.set(query.query_text); - - if (query.chart_type) { - this.chartType.set(query.chart_type); - } - - if (query.widget_options) { - const opts = query.widget_options as Partial; - - if (opts.label_column) this.labelColumn.set(opts.label_column); - if (opts.label_type) this.labelType.set(opts.label_type); - - // Load series - if (opts.series_column) { - this.seriesMode.set('column'); - this.seriesColumn.set(opts.series_column); - if (opts.value_column) this.seriesValueColumn.set(opts.value_column); - } else if (opts.series?.length) { - this.seriesList.set([...opts.series]); - } else if (opts.value_column) { - // Legacy: convert single value_column to series - this.seriesList.set([{ value_column: opts.value_column }]); - } - - // Display options - if (opts.stacked) this.stacked.set(true); - if (opts.horizontal) this.horizontal.set(true); - if (opts.show_data_labels) this.showDataLabels.set(true); - - // Legend - if (opts.legend) { - if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show); - if (opts.legend.position) this.legendPosition.set(opts.legend.position); - } - - // Units - if (opts.units) { - if (opts.units.convert_unit) { - this.unitMode.set('convert'); - this.convertUnit.set(opts.units.convert_unit); - } else if (opts.units.text) { - this.unitMode.set('custom'); - this.unitsText.set(opts.units.text); - if (opts.units.position) this.unitsPosition.set(opts.units.position); - } - } - - // Number format - if (opts.number_format) { - if (opts.number_format.decimal_places !== undefined) { - this.decimalPlaces.set(opts.number_format.decimal_places); - } - if (opts.number_format.thousands_separator !== undefined) { - this.thousandsSeparator.set(opts.number_format.thousands_separator); - } - if (opts.number_format.compact) this.compact.set(true); - } - - // Y-axis - if (opts.y_axis) { - if (opts.y_axis.title) this.yAxisTitle.set(opts.y_axis.title); - if (opts.y_axis.min !== undefined) this.yAxisMin.set(opts.y_axis.min); - if (opts.y_axis.max !== undefined) this.yAxisMax.set(opts.y_axis.max); - if (opts.y_axis.begin_at_zero !== undefined) this.yAxisBeginAtZero.set(opts.y_axis.begin_at_zero); - if (opts.y_axis.scale_type) this.yAxisScaleType.set(opts.y_axis.scale_type); - } - - // X-axis - if (opts.x_axis) { - if (opts.x_axis.title) this.xAxisTitle.set(opts.x_axis.title); - } - - // Sort & limit - if (opts.sort_by) this.sortBy.set(opts.sort_by); - if (opts.limit) this.limit.set(opts.limit); - - // Color palette - if (opts.color_palette?.length) { - this.colorPalette.set([...opts.color_palette]); - } - } - - this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text }); - this.testQuery(); - } - } finally { - this.loading.set(false); - } - } - - onCodeChange(value: string): void { - this.queryText.set(value); - } - - async testQuery(): Promise { - if (!this.queryText().trim()) { - return; - } - - this.testing.set(true); - this.showResults.set(false); - - try { - const result = await this._savedQueries.testQuery(this.connectionId(), { query_text: this.queryText() }); - - if (result) { - this.testResults.set(result.data); - this.executionTime.set(result.execution_time_ms); - this.resultColumns.set(result.data.length > 0 ? Object.keys(result.data[0]) : []); - this.showResults.set(true); - - if (this.resultColumns().length > 0 && !this.labelColumn()) { - this.labelColumn.set(this.resultColumns()[0]); - } - // Auto-add first series if none exist - if (this.seriesList().length === 0 && this.resultColumns().length > 1) { - this.seriesList.set([{ value_column: this.resultColumns()[1] }]); - } - } - } finally { - this.testing.set(false); - } - - this.angulartics2.eventTrack.next({ - action: 'Charts: test query executed', - }); - posthog.capture('Charts: test query executed'); - } - - addSeries(): void { - const cols = this.resultColumns(); - const usedCols = new Set(this.seriesList().map((s) => s.value_column)); - const nextCol = cols.find((c) => c !== this.labelColumn() && !usedCols.has(c)) || cols[0] || ''; - this.seriesList.update((list) => [...list, { value_column: nextCol }]); - } - - removeSeries(index: number): void { - this.seriesList.update((list) => list.filter((_, i) => i !== index)); - } - - updateSeries(index: number, field: keyof ChartSeriesConfig, value: unknown): void { - this.seriesList.update((list) => { - const updated = [...list]; - updated[index] = { ...updated[index], [field]: value }; - return updated; - }); - } - - toHex(color: string | undefined): string { - if (!color) return '#6366f1'; - if (color.startsWith('#')) return color.substring(0, 7); - const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); - if (!match) return '#000000'; - const r = parseInt(match[1]).toString(16).padStart(2, '0'); - const g = parseInt(match[2]).toString(16).padStart(2, '0'); - const b = parseInt(match[3]).toString(16).padStart(2, '0'); - return `#${r}${g}${b}`; - } - - addPaletteColor(): void { - this.colorPalette.update((list) => [...list, '#6366f1']); - } - - removePaletteColor(index: number): void { - this.colorPalette.update((list) => list.filter((_, i) => i !== index)); - } - - updatePaletteColor(index: number, value: string): void { - this.colorPalette.update((list) => { - const updated = [...list]; - updated[index] = value; - return updated; - }); - } - - initializeDefaultPalette(): void { - this.colorPalette.set([...DEFAULT_COLOR_PALETTE]); - } - - async saveQuery(): Promise { - if (!this.queryName().trim() || !this.queryText().trim()) { - return; - } - - this.saving.set(true); - - const widgetOptions = this.currentWidgetOptions(); - - const payload = { - name: this.queryName(), - description: this.queryDescription() || undefined, - query_text: this.queryText(), - widget_type: 'chart' as const, - chart_type: this.chartType(), - widget_options: widgetOptions as unknown as Record, - }; - - try { - if (this.isEditMode()) { - const result = await this._savedQueries.updateSavedQuery(this.connectionId(), this.queryId(), payload); - if (result) this.router.navigate(['/panels', this.connectionId()]); - this.angulartics2.eventTrack.next({ - action: 'Charts: saved query updated', - }); - posthog.capture('Charts: saved query updated'); - } else { - const result = await this._savedQueries.createSavedQuery(this.connectionId(), payload); - if (result) this.router.navigate(['/panels', this.connectionId()]); - this.angulartics2.eventTrack.next({ - action: 'Charts: saved query created', - }); - posthog.capture('Charts: saved query created'); - } - } finally { - this.saving.set(false); - } - } - - async generateWithAi(): Promise { - if (!this.aiDescription().trim()) return; - - this.aiGenerating.set(true); - - try { - const result = await this._dashboards.generateWidgetWithAi(this.connectionId(), { - chart_description: this.aiDescription(), - }); - - if (result) { - this._applyAiResponse(result); - this.aiExpanded.set(false); - this.manualExpanded.set(true); - await this.testQuery(); - } - } finally { - this.aiGenerating.set(false); - } - - this.angulartics2.eventTrack.next({ - action: 'Charts: AI generation used', - }); - posthog.capture('Charts: AI generation used'); - } - - private _applyAiResponse(result: GeneratedPanelWithPosition): void { - if (result.name) this.queryName.set(result.name); - if (result.description) this.queryDescription.set(result.description); - if (result.query_text) { - this.queryText.set(result.query_text); - this.codeModel.set({ language: 'sql', uri: 'query.sql', value: result.query_text }); - } - - if (result.chart_type) { - const validTypes: ChartType[] = ['bar', 'line', 'pie', 'doughnut', 'polarArea']; - if (validTypes.includes(result.chart_type as ChartType)) { - this.chartType.set(result.chart_type as ChartType); - } - } - - if (result.widget_options) { - const opts = result.widget_options as Partial; - - if (opts.label_column) this.labelColumn.set(opts.label_column); - if (opts.label_type) this.labelType.set(opts.label_type); - - // Series - if (opts.series_column) { - this.seriesMode.set('column'); - this.seriesColumn.set(opts.series_column); - if (opts.value_column) this.seriesValueColumn.set(opts.value_column); - } else if (opts.series?.length) { - this.seriesMode.set('manual'); - this.seriesList.set([...opts.series]); - } else if (opts.value_column) { - this.seriesMode.set('manual'); - this.seriesList.set([{ value_column: opts.value_column }]); - } - - // Display options - if (opts.stacked) this.stacked.set(true); - if (opts.horizontal) this.horizontal.set(true); - if (opts.show_data_labels) this.showDataLabels.set(true); - - // Legend - if (opts.legend) { - if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show); - if (opts.legend.position) this.legendPosition.set(opts.legend.position); - } - - // Color palette - if (opts.color_palette?.length) { - this.colorPalette.set([...opts.color_palette]); - } - } - } - - private _loadAiPrerequisites(): void { - const connectionId = this.connectionId(); - if (!connectionId) return; - - // Trigger dashboards loading; the effect in constructor picks up the result - this._dashboards.setActiveConnection(connectionId); - } -} diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.css b/frontend/src/app/components/charts/chart-preview/chart-preview.component.css deleted file mode 100644 index 6f268a57f..000000000 --- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.css +++ /dev/null @@ -1,24 +0,0 @@ -.chart-preview { - width: 100%; - height: 250px; - position: relative; -} - -.chart-preview canvas { - max-width: 100%; - max-height: 100%; -} - -.no-data { - display: flex; - align-items: center; - justify-content: center; - height: 200px; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-data { - color: rgba(255, 255, 255, 0.54); - } -} diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.html b/frontend/src/app/components/charts/chart-preview/chart-preview.component.html deleted file mode 100644 index 0f21f88fa..000000000 --- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.html +++ /dev/null @@ -1,14 +0,0 @@ -@if (chartData) { -
- - -
-} @else { -
-

No chart data available

-
-} diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts deleted file mode 100644 index a78d60397..000000000 --- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js'; -import 'chartjs-adapter-date-fns'; -import { BaseChartDirective } from 'ng2-charts'; -import { - buildChartData, - buildChartOptions, - buildDataLabelsPlugin, - getMappedChartType, -} from 'src/app/lib/chart-config.helper'; -import { ChartType, ChartWidgetOptions } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-chart-preview', - templateUrl: './chart-preview.component.html', - styleUrls: ['./chart-preview.component.css'], - imports: [CommonModule, BaseChartDirective], -}) -export class ChartPreviewComponent implements OnChanges { - @Input() chartType: ChartType = 'bar'; - @Input() data: Record[] = []; - @Input() widgetOptions: ChartWidgetOptions | null = null; - - // Legacy inputs for backward compatibility - @Input() labelColumn = ''; - @Input() valueColumn = ''; - @Input() labelType: 'values' | 'datetime' = 'values'; - - public chartData: ChartData | null = null; - public chartOptions: ChartConfiguration['options'] = this._getDefaultOptions(); - public chartPlugins: Plugin[] = []; - - ngOnChanges(changes: SimpleChanges): void { - if ( - changes['data'] || - changes['widgetOptions'] || - changes['labelColumn'] || - changes['valueColumn'] || - changes['chartType'] || - changes['labelType'] - ) { - this._updateChart(); - } - } - - get mappedChartType(): ChartJsType { - const options = this._resolveOptions(); - return getMappedChartType(this.chartType, options); - } - - private _updateChart(): void { - const options = this._resolveOptions(); - - if (!this.data.length || !options.label_column) { - this.chartData = null; - return; - } - - // Check that at least one value source exists - const hasValues = options.series?.length || options.value_column; - if (!hasValues) { - this.chartData = null; - return; - } - - this.chartData = buildChartData(this.data, this.chartType, options); - this.chartOptions = buildChartOptions(this.chartType, options); - - this.chartPlugins = [buildDataLabelsPlugin(options)]; - } - - private _resolveOptions(): ChartWidgetOptions { - if (this.widgetOptions) { - return this.widgetOptions; - } - - // Legacy mode: build options from individual inputs - return { - label_column: this.labelColumn, - value_column: this.valueColumn, - label_type: this.labelType, - }; - } - - private _getDefaultOptions(): ChartConfiguration['options'] { - return { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'top', - }, - }, - }; - } -} diff --git a/frontend/src/app/components/charts/charts-list/charts-list.component.css b/frontend/src/app/components/charts/charts-list/charts-list.component.css deleted file mode 100644 index 8ece1d8c0..000000000 --- a/frontend/src/app/components/charts/charts-list/charts-list.component.css +++ /dev/null @@ -1,230 +0,0 @@ -:host { - display: block; - height: calc(100vh - 44px); - margin-bottom: -60px; - overflow: hidden; - position: relative; - z-index: 1; -} - -.charts-layout { - display: flex; - height: 100%; -} - -.charts-main { - flex: 1; - overflow-y: auto; - background-color: var(--mat-sidenav-content-background-color); -} - -.charts-page { - margin: 3em auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1400px; -} - -@media (width <= 600px) { - .charts-page { - padding: 0 16px; - margin: 1.5em auto; - } -} - -.charts-header { - margin-bottom: 32px; -} - -.charts-description { - color: rgba(0, 0, 0, 0.64); - margin-top: 8px; -} - -@media (prefers-color-scheme: dark) { - .charts-description { - color: rgba(255, 255, 255, 0.7); - } -} - -.charts-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; -} - -@media (width <= 600px) { - .charts-toolbar { - flex-direction: column; - align-items: stretch; - } -} - -.search-field { - flex: 1; - max-width: 400px; -} - -@media (width <= 600px) { - .search-field { - max-width: 100%; - } -} - -/* Table Styles */ -.panels-table { - width: 100%; - background: transparent; -} - -.panels-table tr.mat-mdc-row { - height: 64px; - cursor: pointer; -} - -.panels-table tr.mat-mdc-row:hover { - background-color: rgba(0, 0, 0, 0.04); -} - -@media (prefers-color-scheme: dark) { - .panels-table tr.mat-mdc-row:hover { - background-color: rgba(255, 255, 255, 0.04); - } -} - -.panels-table th.mat-mdc-header-cell { - font-weight: 500; - color: rgba(0, 0, 0, 0.6); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -@media (prefers-color-scheme: dark) { - .panels-table th.mat-mdc-header-cell { - color: rgba(255, 255, 255, 0.6); - } -} - -/* Preview Cell */ -.preview-cell { - width: 64px; - padding-right: 8px !important; -} - -.preview-link { - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - overflow: hidden; -} - -.preview-link app-chart-mini-preview { - width: 48px; - height: 40px; -} - -/* Name Cell */ -.panel-name-cell { - font-weight: 500; -} - -/* Description Cell */ -.description-cell { - color: rgba(0, 0, 0, 0.6); - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .description-cell { - color: rgba(255, 255, 255, 0.6); - } -} - -/* Updated Cell */ -.updated-cell { - color: rgba(0, 0, 0, 0.5); - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .updated-cell { - color: rgba(255, 255, 255, 0.5); - } -} - -/* Actions Cell */ -.actions-cell { - width: 48px; - text-align: right; -} - -/* Empty State */ -.no-panels { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 64px 24px; - text-align: center; - background-color: rgba(0, 0, 0, 0.02); - border-radius: 8px; -} - -@media (prefers-color-scheme: dark) { - .no-panels { - background-color: rgba(255, 255, 255, 0.05); - } -} - -.no-panels-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: rgba(0, 0, 0, 0.26); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .no-panels-icon { - color: rgba(255, 255, 255, 0.3); - } -} - -.no-panels h3 { - margin: 0 0 8px 0; - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .no-panels h3 { - color: rgba(255, 255, 255, 0.87); - } -} - -.no-panels p { - margin: 0 0 16px 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-panels p { - color: rgba(255, 255, 255, 0.54); - } -} - -.delete-action { - color: var(--color-warnPalette-500); -} - -@media (prefers-color-scheme: dark) { - .delete-action { - color: var(--color-warnDarkPalette-500); - } -} diff --git a/frontend/src/app/components/charts/charts-list/charts-list.component.html b/frontend/src/app/components/charts/charts-list/charts-list.component.html deleted file mode 100644 index 10e43c925..000000000 --- a/frontend/src/app/components/charts/charts-list/charts-list.component.html +++ /dev/null @@ -1,120 +0,0 @@ -
- - - -
- - -
-
-

Panels

-

- Create and manage panels with chart visualizations for your dashboards. -

-
- -
- - Search panels - - search - - - - add - Create panel - -
- - - -
- widgets -

No panels found

-

No panels match your search criteria.

-

Create your first panel to visualize your data.

- - add - Create panel - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Type - - Name - {{query.name}} - Description - {{query.description || '—'}} - Updated - {{formatUpdatedAt(query.updated_at)}} - - - - - edit - Edit - - - - -
-
-
-
diff --git a/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts b/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts deleted file mode 100644 index 5aa4f444d..000000000 --- a/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NO_ERRORS_SCHEMA, Signal, signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { SavedQuery } from 'src/app/models/saved-query'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { QueryUpdateEvent, SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { DashboardsSidebarComponent } from '../../dashboards/dashboards-sidebar/dashboards-sidebar.component'; -import { ChartDeleteDialogComponent } from '../chart-delete-dialog/chart-delete-dialog.component'; -import { ChartsListComponent } from './charts-list.component'; - -type ChartsListComponentTestable = ChartsListComponent & { - savedQueries: Signal; - loading: Signal; - searchQuery: WritableSignal; - filteredQueries: Signal; -}; - -describe('ChartsListComponent', () => { - let component: ChartsListComponent; - let fixture: ComponentFixture; - let mockSavedQueriesService: Partial; - let mockConnectionsService: Partial; - let mockDialog: Partial; - let queriesUpdatedSignal: WritableSignal; - let savedQueriesSignal: WritableSignal; - let savedQueriesLoadingSignal: WritableSignal; - - const mockSavedQuery: SavedQuery = { - id: '1', - name: 'Test Query', - description: 'Test description', - widget_type: 'chart', - chart_type: 'bar', - widget_options: null, - query_text: 'SELECT * FROM users', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }; - - beforeEach(async () => { - queriesUpdatedSignal = signal(''); - savedQueriesSignal = signal([mockSavedQuery]); - savedQueriesLoadingSignal = signal(false); - - mockSavedQueriesService = { - savedQueries: savedQueriesSignal.asReadonly(), - savedQueriesLoading: savedQueriesLoadingSignal.asReadonly(), - queriesUpdated: queriesUpdatedSignal.asReadonly(), - setActiveConnection: vi.fn(), - refreshSavedQueries: vi.fn(), - }; - - mockConnectionsService = { - getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), - }; - - mockDialog = { - open: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [ - ChartsListComponent, - BrowserAnimationsModule, - MatSnackBarModule, - RouterTestingModule, - Angulartics2Module.forRoot(), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: MatDialog, useValue: mockDialog }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - paramMap: { - get: () => 'conn-1', - }, - }, - }, - }, - ], - }) - .overrideComponent(ChartsListComponent, { - remove: { imports: [DashboardsSidebarComponent] }, - add: { schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(ChartsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set active connection on init', () => { - expect(mockSavedQueriesService.setActiveConnection).toHaveBeenCalledWith('conn-1'); - }); - - it('should set page title on init', () => { - expect(mockConnectionsService.getCurrentConnectionTitle).toHaveBeenCalled(); - }); - - it('should initialize with loading from service', () => { - const testable = component as ChartsListComponentTestable; - expect(testable.loading()).toBe(false); - }); - - it('should have correct displayed columns', () => { - expect(component.displayedColumns).toEqual(['preview', 'name', 'description', 'updated', 'actions']); - }); - - describe('filteredQueries computed', () => { - it('should show all queries when search is empty', () => { - const testable = component as ChartsListComponentTestable; - savedQueriesSignal.set([mockSavedQuery]); - testable.searchQuery.set(''); - expect(testable.filteredQueries()).toEqual([mockSavedQuery]); - }); - - it('should filter by name', () => { - const testable = component as ChartsListComponentTestable; - savedQueriesSignal.set([mockSavedQuery]); - testable.searchQuery.set('Test'); - expect(testable.filteredQueries()).toEqual([mockSavedQuery]); - }); - - it('should filter by description', () => { - const testable = component as ChartsListComponentTestable; - savedQueriesSignal.set([mockSavedQuery]); - testable.searchQuery.set('description'); - expect(testable.filteredQueries()).toEqual([mockSavedQuery]); - }); - - it('should return empty when no match', () => { - const testable = component as ChartsListComponentTestable; - savedQueriesSignal.set([mockSavedQuery]); - testable.searchQuery.set('nonexistent'); - expect(testable.filteredQueries()).toEqual([]); - }); - }); - - describe('openDeleteDialog', () => { - it('should open delete dialog with query data', () => { - component.openDeleteDialog(mockSavedQuery); - expect(mockDialog.open).toHaveBeenCalledWith(ChartDeleteDialogComponent, { - width: '400px', - data: { query: mockSavedQuery, connectionId: 'conn-1' }, - }); - }); - }); - - describe('queriesUpdated effect', () => { - it('should refresh queries when queriesUpdated signal changes', () => { - vi.mocked(mockSavedQueriesService.refreshSavedQueries!).mockClear(); - - // Trigger the signal change - queriesUpdatedSignal.set('created'); - TestBed.flushEffects(); - - expect(mockSavedQueriesService.refreshSavedQueries).toHaveBeenCalled(); - }); - - it('should not refresh queries when queriesUpdated signal is empty', () => { - vi.mocked(mockSavedQueriesService.refreshSavedQueries!).mockClear(); - - // Set empty value - queriesUpdatedSignal.set(''); - TestBed.flushEffects(); - - expect(mockSavedQueriesService.refreshSavedQueries).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/components/charts/charts-list/charts-list.component.ts b/frontend/src/app/components/charts/charts-list/charts-list.component.ts deleted file mode 100644 index e43a103a5..000000000 --- a/frontend/src/app/components/charts/charts-list/charts-list.component.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatDialog } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { ChartType, SavedQuery } from 'src/app/models/saved-query'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { ChartMiniPreviewComponent } from '../../dashboards/chart-mini-preview/chart-mini-preview.component'; -import { DashboardsSidebarComponent } from '../../dashboards/dashboards-sidebar/dashboards-sidebar.component'; -import { PlaceholderTableDataComponent } from '../../skeletons/placeholder-table-data/placeholder-table-data.component'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { ChartDeleteDialogComponent } from '../chart-delete-dialog/chart-delete-dialog.component'; - -@Component({ - selector: 'app-charts-list', - templateUrl: './charts-list.component.html', - styleUrls: ['./charts-list.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatButtonModule, - MatCardModule, - MatIconModule, - MatMenuModule, - MatInputModule, - MatFormFieldModule, - MatTooltipModule, - MatDividerModule, - MatTableModule, - ChartMiniPreviewComponent, - PlaceholderTableDataComponent, - AlertComponent, - DashboardsSidebarComponent, - ], -}) -export class ChartsListComponent implements OnInit { - protected searchQuery = signal(''); - protected connectionId = signal(''); - displayedColumns = ['preview', 'name', 'description', 'updated', 'actions']; - - private chartTypeIcons: Record = { - bar: 'bar_chart', - line: 'show_chart', - pie: 'pie_chart', - doughnut: 'donut_large', - polarArea: 'radar', - }; - - getChartIcon(chartType: ChartType | null): string { - return chartType ? this.chartTypeIcons[chartType] : 'bar_chart'; - } - - getChartTypeName(chartType: ChartType | null): string { - const names: Record = { - bar: 'Bar Chart', - line: 'Line Chart', - pie: 'Pie Chart', - doughnut: 'Doughnut Chart', - polarArea: 'Polar Area Chart', - }; - return chartType ? names[chartType] : 'Bar Chart'; - } - - private _savedQueries = inject(SavedQueriesService); - private _connections = inject(ConnectionsService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private dialog = inject(MatDialog); - private angulartics2 = inject(Angulartics2); - private title = inject(Title); - - // Use service signals for saved queries and loading - protected savedQueries = computed(() => this._savedQueries.savedQueries()); - protected loading = computed(() => this._savedQueries.savedQueriesLoading()); - - protected filteredQueries = computed(() => { - const queries = this.savedQueries(); - const search = this.searchQuery(); - if (!search) return queries; - const query = search.toLowerCase(); - return queries.filter( - (q) => q.name.toLowerCase().includes(query) || (q.description && q.description.toLowerCase().includes(query)), - ); - }); - - private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' }); - - constructor() { - // Connection title effect - effect(() => { - const title = this.connectionTitle(); - this.title.setTitle(`Charts | ${title || 'Rocketadmin'}`); - }); - - // Queries update effect - effect(() => { - const action = this._savedQueries.queriesUpdated(); - if (action) this._savedQueries.refreshSavedQueries(); - }); - } - - ngOnInit(): void { - const connId = this.route.snapshot.paramMap.get('connection-id') || ''; - this.connectionId.set(connId); - this._savedQueries.setActiveConnection(connId); - } - - trackCreatePageOpened(): void { - this.angulartics2.eventTrack.next({ - action: 'Charts: create chart page opened', - }); - posthog.capture('Charts: create chart page opened'); - } - - trackEditPageOpened(): void { - this.angulartics2.eventTrack.next({ - action: 'Charts: edit chart page opened', - }); - posthog.capture('Charts: edit chart page opened'); - } - - openQuery(query: SavedQuery): void { - this.trackEditPageOpened(); - this.router.navigate(['/panels', this.connectionId(), query.id]); - } - - openDeleteDialog(query: SavedQuery): void { - this.dialog.open(ChartDeleteDialogComponent, { - width: '400px', - data: { query, connectionId: this.connectionId() }, - }); - this.angulartics2.eventTrack.next({ - action: 'Charts: delete chart dialog opened', - }); - posthog.capture('Charts: delete chart dialog opened'); - } - - formatUpdatedAt(date: string): string { - const now = new Date(); - const updated = new Date(date); - const diffMs = now.getTime() - updated.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins} min ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; - if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; - - return updated.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - } -} diff --git a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.css b/frontend/src/app/components/company-member-invitation/company-member-invitation.component.css deleted file mode 100644 index a10f35f1f..000000000 --- a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.css +++ /dev/null @@ -1,41 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - margin: 0 auto; - padding: 16px; - max-width: 520px; - min-width: 300px; - width: 90%; -} - -.password-form__title { - margin-bottom: 32px; -} - -.name-input { - width: 100%; -} - -.form-divider { - margin-top: 48px; - margin-bottom: 20px; -} - -.actions { - display: flex; - justify-content: flex-end; -} - -.password-form__back { - margin-right: 20px; -} diff --git a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.html b/frontend/src/app/components/company-member-invitation/company-member-invitation.component.html deleted file mode 100644 index af6f4f8c2..000000000 --- a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - -

Invitation link is invalid

-

The link you are trying to use is invalid. Please contact the person who sent you the link.

- Go to login page -
- -
- -
-
-

You were invited to {{companyName || 'Unknown company'}} company

-

Create password for your account in this company.

- - - - -

Add name visible to your coworkers.

- - - Name - - - -
- -
-
-
-
diff --git a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.spec.ts b/frontend/src/app/components/company-member-invitation/company-member-invitation.component.spec.ts deleted file mode 100644 index 6078868b2..000000000 --- a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; -import { Angulartics2Module } from 'angulartics2'; -import { CompanyMemberInvitationComponent } from './company-member-invitation.component'; - -describe('CompanyMemberInvitationComponent', () => { - let component: CompanyMemberInvitationComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, Angulartics2Module.forRoot(), CompanyMemberInvitationComponent], - providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }], - }).compileComponents(); - - fixture = TestBed.createComponent(CompanyMemberInvitationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.ts b/frontend/src/app/components/company-member-invitation/company-member-invitation.component.ts deleted file mode 100644 index 04c6ba7db..000000000 --- a/frontend/src/app/components/company-member-invitation/company-member-invitation.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { map } from 'rxjs/operators'; -import { AuthService } from 'src/app/services/auth.service'; -import { CompanyService } from 'src/app/services/company.service'; -import { PlaceholderCompanyInvitationComponent } from '../skeletons/placeholder-company-invitation/placeholder-company-invitation.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { BannerComponent } from '../ui-components/banner/banner.component'; -import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; - -@Component({ - selector: 'app-company-member-invitation', - templateUrl: './company-member-invitation.component.html', - styleUrls: ['./company-member-invitation.component.css'], - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatDividerModule, - PlaceholderCompanyInvitationComponent, - BannerComponent, - AlertComponent, - UserPasswordComponent, - ], -}) -export class CompanyMemberInvitationComponent implements OnInit { - public token: string; - public companyName: string = null; - public password: string = ''; - public userName: string; - public submitting: boolean; - public checkingLink: boolean = true; - public isLinkAvailable: boolean = false; - - constructor( - private _auth: AuthService, - private _company: CompanyService, - private router: Router, - private route: ActivatedRoute, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.route.paramMap - .pipe( - map((params: ParamMap) => { - this.token = params.get('verification-token'); - const companyId = params.get('company-id'); - this._company.fetchCompanyName(companyId).subscribe((res) => (this.companyName = res.name)); - this._auth.checkInvitationAvailability(this.token).subscribe((res) => { - this.isLinkAvailable = res.success; - this.checkingLink = false; - }); - }), - ) - .subscribe(); - } - - updatePasswordField(updatedValue: string) { - this.password = updatedValue; - } - - acceptInvitation() { - this._auth.acceptCompanyInvitation(this.token, this.password, this.userName).subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Company invitation: invitation is accepted successfully', - }); - posthog.capture('Company invitation: invitation is accepted successfully'); - this.router.navigate(['/connections-list']); - }); - } -} diff --git a/frontend/src/app/components/company/company.component.css b/frontend/src/app/components/company/company.component.css deleted file mode 100644 index 332125a2d..000000000 --- a/frontend/src/app/components/company/company.component.css +++ /dev/null @@ -1,303 +0,0 @@ -.profile-layout { - display: flex; - height: calc(100vh - 64px); -} - -.profile-main { - flex: 1; - background-color: var(--mat-sidenav-content-background-color); - overflow-y: auto; -} - -::ng-deep .profile-main > app-alert { - position: relative; - top: 0; - margin: 24px; -} - -.companyPage { - margin: var(--top-margin) auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1200px; -} - -@media (width <= 600px) { - .companyPage { - padding: 0 16px; - margin: 1.5em auto; - } -} - -.companyPageHeader { - margin-bottom: 40px; -} - -.text-filed-edit { - position: relative; -} - -.text-filed-edit__input { - width: 100%; -} - -.text-filed-edit__buttons { - position: absolute; - right: 4px; - top: 50%; - transform: translateY(calc(-50% - 10px)); - display: flex; - gap: 4px; -} - -.tableHeader { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 24px; -} - -@media (width <= 600px) { - .tableHeader__heading { - display: flex; - flex-direction: column; - } -} - -.tableHeader__actions { - display: flex; - gap: 8px; - margin-bottom: 16px; -} - -.company-member-cell__label { - display: none; -} - -.company-member-cell_email { - max-width: 216px; -} - -@media (width <= 600px) { - .company-member-cell_email { - max-width: 100%; - word-break: break-all; - } -} - -.company-member-cell_email-value { - display: flex; - align-items: center; - gap: 8px; -} - -.company-member-cell_email-icon { - flex-shrink: 0; - font-size: 16px; - width: 16px; - height: 16px; -} - -.company-member-cell_name { - max-width: 200px; -} - -@media (width <= 600px) { - .company-member-cell_name { - max-width: 100%; - word-break: break-word; - } -} - -.company-member-cell_accessed { - color: var(--color-successPalette-500); -} - -.access-popup { - background: var(--mat-menu-container-color, #fff); - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - padding: 8px 12px; - min-width: 160px; - white-space: nowrap; - display: flex; - flex-direction: column; - font-family: var(--mat-menu-item-label-text-font, inherit); - font-size: var(--mat-menu-item-label-text-size, 14px); -} - -.access-popup__connection { - display: flex; - flex-direction: column; -} - -.access-popup__connection-title { - font-weight: 500; -} - -.access-popup__connection-db { - font-size: 0.786em; - color: rgba(0, 0, 0, 0.45); - padding-left: 2px; -} - -@media (prefers-color-scheme: dark) { - .access-popup__connection-db { - color: rgba(255, 255, 255, 0.45); - } -} - -.access-popup__group { - font-size: 0.857em; - color: rgba(0, 0, 0, 0.6); - padding-left: 8px; -} - -@media (prefers-color-scheme: dark) { - .access-popup__group { - color: rgba(255, 255, 255, 0.6); - } -} - -.company-member-cell_not-accessed { - color: var(--color-warnPalette-500); -} - -.company-member-cell_content-center { - text-align: center; -} - -@media (width <= 600px) { - .company-member-cell_content-center { - text-align: initial; - } -} - -.company-members-table { - margin-bottom: 32px; -} - -@media (width <= 600px) { - .company-members-table { - display: grid; - grid-template-columns: auto 1fr; - max-width: 100%; - } - - .company-members-table-heading { - display: none; - } - - .company-members-table ::ng-deep tbody { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - } - - .company-member-row { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - grid-gap: 12px 28px; - border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); - border-bottom-width: var(--mat-table-row-item-outline-width, 1px); - border-bottom-style: solid; - height: auto; - padding: 20px 0; - } - - .company-member-cell { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - border-bottom: none; - } - - .company-member-cell::before { - content: attr(data-label); - display: inline-block; - font-weight: bold; - white-space: nowrap; - } - - .company-member-cell-action { - grid-column: 1 / span 3; - display: flex; - justify-content: flex-end; - border-bottom: none; - } - - .active-status-toggle { - padding-bottom: 4px; - } -} - -.table-icon { - margin-bottom: -8px; -} - -.role-option { - padding-top: 4px; - padding-bottom: 4px; -} - -.role-option-header { - display: flex; - align-items: center; -} - -.role-option-header__mark { - font-size: 14px; - margin-bottom: -10px; - font-weight: 600; - margin-left: 4px; -} - -.role-option__description { - color: rgba(0, 0, 0, 0.64); - font-size: 13px; - margin-top: 2px; -} - -.cname-hint { - margin-top: 2px; -} - -.cname-hint_dimmed { - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .cname-hint_dimmed { - color: rgba(255, 255, 255, 0.7); - } -} - -.cname-value { - position: relative; - padding: 0 4px; -} - -.cname-value::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: var(--color-accentedPalette-200); - border-radius: 4px; - height: 100%; - opacity: 0.5; - width: 100%; - z-index: -1; -} - -@media (prefers-color-scheme: dark) { - .cname-value::after { - background-color: var(--color-accentedPalette-700); - } -} - -.cname-instructions { - list-style: none; -} diff --git a/frontend/src/app/components/company/company.component.html b/frontend/src/app/components/company/company.component.html deleted file mode 100644 index d1cdb76d3..000000000 --- a/frontend/src/app/components/company/company.component.html +++ /dev/null @@ -1,265 +0,0 @@ -
- - -
- - - - -
-

- {{ company.name }} company settings -

- -
- - Company name - - -
- -
-
- -
-

Members

-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Email -
- - mail - - - - {{element.email}} -
-
Name - {{element.name || '—'}} - Role -
- - - - - - -
- - - - - - {{element.role === "ADMIN" ? 'Account Owner' : null}} - {{element.role === "DB_ADMIN" ? 'System Admin' : null}} - {{element.role === "USER" ? 'Member' : null}} - - -
2fa -
- - done - - - remove - -
-
Suspended - - - Access - @if (element.has_groups) { - check_circle - -
- @for (conn of element.user_membership; track conn.id) { -
- {{ conn.title }} - {{ conn.database }} - @for (group of conn.groups; track group.id) { - {{ group.title }} - } -
- } -
-
- } @else { - cancel - } -
- - - - -
-
-
-
diff --git a/frontend/src/app/components/company/company.component.spec.ts b/frontend/src/app/components/company/company.component.spec.ts deleted file mode 100644 index 77e46bb75..000000000 --- a/frontend/src/app/components/company/company.component.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { Company, CompanyMember, CompanyMemberRole } from 'src/app/models/company'; -import { SubscriptionPlans } from 'src/app/models/user'; -import { CompanyService } from 'src/app/services/company.service'; -import { UserService } from 'src/app/services/user.service'; -import { CompanyComponent } from './company.component'; -import { DeleteMemberDialogComponent } from './delete-member-dialog/delete-member-dialog.component'; -import { InviteMemberDialogComponent } from './invite-member-dialog/invite-member-dialog.component'; -import { RevokeInvitationDialogComponent } from './revoke-invitation-dialog/revoke-invitation-dialog.component'; - -describe('CompanyComponent', () => { - let component: CompanyComponent; - let fixture: ComponentFixture; - let dialog: MatDialog; - - const fakeCompanyService = { - isCustomDomain: vi.fn(), - fetchCompany: vi.fn(), - fetchCompanyMembers: vi.fn(), - getCustomDomain: vi.fn(), - updateCompanyName: vi.fn(), - updateCompanyMemberRole: vi.fn(), - cast: of(''), - }; - const fakeUserService = { - cast: of({}), - }; - - const mockCompany: Company = { - id: 'company-12345678', - name: 'My company', - additional_info: null, - subscriptionLevel: SubscriptionPlans.free, - address: {}, - connections: [ - { - id: '12345678', - createdAt: '2024-02-12T16:54:56.482Z', - updatedAt: '2024-02-12T17:08:12.643Z', - title: 'Test DB', - author: { - id: 'author-1', - isActive: false, - email: 'author1@test.com', - createdAt: '2024-01-06T21:11:36.746Z', - name: 'John Smith', - is_2fa_enabled: false, - role: 'ADMIN', - }, - groups: [ - { - id: '0955923f-9101-4fa9-95ca-b003a5c7ce89', - isMain: true, - title: 'Admin', - users: [ - { - id: 'a06ee7bf-e6c9-4c1a-a5aa-e9ba09e3e8a1', - isActive: false, - email: 'author1@test.com', - createdAt: '2024-01-06T21:11:36.746Z', - name: null, - is_2fa_enabled: false, - role: CompanyMemberRole.CAO, - }, - ], - }, - ], - }, - ], - invitations: [ - { - id: 'invitation1', - verification_string: 'verification_string_12345678', - groupId: null, - inviterId: 'user1', - invitedUserEmail: 'admin1@test.com', - role: CompanyMemberRole.CAO, - }, - ], - }; - - const mockMembers = [ - { - id: '61582cb5-5577-43d2-811f-900668ecfff1', - isActive: true, - email: 'user1@test.com', - createdAt: '2024-02-05T09:41:08.199Z', - name: 'User 3333', - is_2fa_enabled: false, - role: 'USER', - }, - { - id: 'a06ee7bf-e6c9-4c1a-a5aa-e9ba09e3e8a1', - isActive: false, - email: 'admin0@test.com', - createdAt: '2024-01-06T21:11:36.746Z', - name: null, - is_2fa_enabled: false, - role: 'ADMIN', - }, - ]; - - const mockCompanyDomain = { - success: false, - domain_info: null, - }; - - beforeEach(() => { - fakeCompanyService.cast = of(''); - fakeCompanyService.fetchCompany.mockReturnValue(of(mockCompany)); - fakeCompanyService.fetchCompanyMembers.mockReturnValue(of(mockMembers)); - fakeCompanyService.getCustomDomain.mockReturnValue(of(mockCompanyDomain)); - fakeCompanyService.updateCompanyName.mockReturnValue(of({})); - fakeCompanyService.updateCompanyMemberRole.mockReturnValue(of({})); - fakeUserService.cast = of(mockMembers[1]); - }); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - Angulartics2Module.forRoot(), - FormsModule, - MatInputModule, - BrowserAnimationsModule, - CompanyComponent, - ], - providers: [ - provideRouter([]), - provideHttpClient(), - { provide: CompanyService, useValue: fakeCompanyService }, - { provide: UserService, useValue: fakeUserService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(CompanyComponent); - component = fixture.componentInstance; - dialog = TestBed.inject(MatDialog); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set initial values and receive company members', () => { - vi.spyOn(fakeCompanyService.cast, 'subscribe'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.company).toEqual(mockCompany); - expect(fakeCompanyService.fetchCompanyMembers).toHaveBeenCalledWith(mockCompany.id); - expect(fakeCompanyService.cast.subscribe).toHaveBeenCalled(); - }); - - it('should receive company info and members when invitation was sent', () => { - fakeCompanyService.cast = of('invited'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(fakeCompanyService.fetchCompany).toHaveBeenCalledWith(); - expect(fakeCompanyService.fetchCompanyMembers).toHaveBeenCalledWith(mockCompany.id); - }); - - it('should receive company members when a member was deleted', () => { - fakeCompanyService.cast = of('deleted'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(fakeCompanyService.fetchCompanyMembers).toHaveBeenCalledWith(mockCompany.id); - }); - - it('should get company members and mix it with invitation, and set the value', () => { - fakeCompanyService.cast = of('deleted'); - - component.getCompanyMembers('company-12345678'); - - expect(component.members).toEqual([ - { - id: 'a06ee7bf-e6c9-4c1a-a5aa-e9ba09e3e8a1', - isActive: false, - email: 'admin0@test.com', - createdAt: '2024-01-06T21:11:36.746Z', - name: null, - is_2fa_enabled: false, - role: 'ADMIN', - }, - { - id: '61582cb5-5577-43d2-811f-900668ecfff1', - isActive: true, - email: 'user1@test.com', - createdAt: '2024-02-05T09:41:08.199Z', - name: 'User 3333', - is_2fa_enabled: false, - role: 'USER', - }, - { - id: 'invitation1', - verification_string: 'verification_string_12345678', - groupId: null, - inviterId: 'user1', - invitedUserEmail: 'admin1@test.com', - role: CompanyMemberRole.CAO, - pending: true, - email: 'admin1@test.com', - }, - ]); - expect(component.adminsCount).toBe(1); - }); - - it('should open Add member dialog and pass company id and name', () => { - const fakeAddMemberDialogOpen = vi.spyOn(dialog, 'open'); - component.company = mockCompany; - - component.handleAddMemberDialogOpen(); - expect(fakeAddMemberDialogOpen).toHaveBeenCalledWith(InviteMemberDialogComponent, { - width: '25em', - data: mockCompany, - }); - }); - - it('should open Delete member dialog and pass company id and member', () => { - const fakeDeleteMemberDialogOpen = vi.spyOn(dialog, 'open'); - component.company.id = 'company-12345678'; - const fakeMember: CompanyMember = { - id: '61582cb5-5577-43d2-811f-900668ecfff1', - isActive: true, - email: 'user1@test.com', - name: 'User 3333', - is_2fa_enabled: false, - role: CompanyMemberRole.Member, - has_groups: false, - user_membership: [], - }; - - component.handleDeleteMemberDialogOpen(fakeMember); - expect(fakeDeleteMemberDialogOpen).toHaveBeenCalledWith(DeleteMemberDialogComponent, { - width: '25em', - data: { companyId: 'company-12345678', user: fakeMember }, - }); - }); - - it('should open Revoke invitation dialog and pass company id and member email', () => { - const fakeRevokeInvitationDialogOpen = vi.spyOn(dialog, 'open'); - component.company.id = 'company-12345678'; - - component.handleRevokeInvitationDialogOpen('user1@test.com'); - expect(fakeRevokeInvitationDialogOpen).toHaveBeenCalledWith(RevokeInvitationDialogComponent, { - width: '25em', - data: { companyId: 'company-12345678', userEmail: 'user1@test.com' }, - }); - }); - - it('should call update company name', () => { - component.company.id = 'company-12345678'; - component.company.name = 'New company name'; - - component.changeCompanyName(); - expect(fakeCompanyService.updateCompanyName).toHaveBeenCalledWith('company-12345678', 'New company name'); - }); - - it('should call update company member role to ADMIN and request company members list', () => { - component.company.id = 'company-12345678'; - component.company.name = 'New company name'; - - component.updateRole('user-12345678', CompanyMemberRole.CAO); - expect(fakeCompanyService.updateCompanyMemberRole).toHaveBeenCalledWith( - 'company-12345678', - 'user-12345678', - 'ADMIN', - ); - expect(fakeCompanyService.fetchCompanyMembers).toHaveBeenCalledWith('company-12345678'); - }); -}); diff --git a/frontend/src/app/components/company/company.component.ts b/frontend/src/app/components/company/company.component.ts deleted file mode 100644 index f9eabd12f..000000000 --- a/frontend/src/app/components/company/company.component.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { NgIf } from '@angular/common'; -import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { orderBy } from 'lodash-es'; -import posthog from 'posthog-js'; -import { Company, CompanyMember, CompanyMemberRole } from 'src/app/models/company'; -import { CompanyService } from 'src/app/services/company.service'; -import { UserService } from 'src/app/services/user.service'; -import { environment } from 'src/environments/environment'; -import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; -import { PlaceholderCompanyComponent } from '../skeletons/placeholder-company/placeholder-company.component'; -import { PlaceholderTableDataComponent } from '../skeletons/placeholder-table-data/placeholder-table-data.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { DeleteDomainDialogComponent } from './delete-domain-dialog/delete-domain-dialog.component'; -import { DeleteMemberDialogComponent } from './delete-member-dialog/delete-member-dialog.component'; -import { InviteMemberDialogComponent } from './invite-member-dialog/invite-member-dialog.component'; -import { RevokeInvitationDialogComponent } from './revoke-invitation-dialog/revoke-invitation-dialog.component'; - -@Component({ - selector: 'app-company', - templateUrl: './company.component.html', - styleUrls: ['./company.component.css'], - imports: [ - NgIf, - CdkOverlayOrigin, - CdkConnectedOverlay, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatSelectModule, - MatSlideToggleModule, - MatTooltipModule, - MatTableModule, - MatMenuModule, - RouterModule, - Angulartics2OnModule, - AlertComponent, - PlaceholderCompanyComponent, - PlaceholderTableDataComponent, - ProfileSidebarComponent, - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class CompanyComponent { - protected posthog = posthog; - CompanyMemberRole = CompanyMemberRole; - public isSaas = (environment as any).saas; - public company: Company = null; - public members: any = null; - public submitting: boolean; - public adminsCount: number; - public unsuspendedAdminsCount: number; - public membersTableDisplayedColumns: string[]; - // public invitationsTableDisplayColumns: string[] = ['invitedUserEmail', 'role']; - public submittingChangedName: boolean = false; - public currentUser; - public submittingUsersChange: boolean = false; - public companyCustomDomain: { - id: string; - companyId: string; - hostname: string; - } = { - id: null, - companyId: '', - hostname: '', - }; - - public companyCustomDomainHostname: string; - public companyCustomDomainPlaceholder: string; - public companyCustomDomainThirdLevel: string; - public submittingCustomDomain: boolean = false; - public isCustomDomain: boolean = false; - - public companyRolesName = { - ADMIN: 'Account Owner', - DB_ADMIN: 'System Admin', - USER: 'Member', - }; - - public authProviderIcons = { - GOOGLE: 'google', - GITHUB: 'github', - }; - - constructor( - public _company: CompanyService, - public _user: UserService, - public dialog: MatDialog, - private angulartics2: Angulartics2, - private title: Title, - ) {} - - ngOnInit() { - this.isCustomDomain = this._company.isCustomDomain() && this.isSaas; - - this.title.setTitle('Company settings | Rocketadmin'); - - this._company.fetchCompany().subscribe((res) => { - this.company = res; - this.getCompanyMembers(res.id); - if (this.isCustomDomain) { - this.companyCustomDomainHostname = res.custom_domain; - } else { - this.getCompanyCustomDomain(res.id); - } - }); - - this._company.cast.subscribe((arg) => { - if (arg === 'invited' || arg === 'revoked' || arg === 'role' || arg === 'suspended' || arg === 'unsuspended') { - this.submittingUsersChange = true; - this._company.fetchCompany().subscribe((res) => { - this.company = res; - this.getCompanyMembers(res.id); - this.submittingUsersChange = false; - }); - } else if (arg === 'deleted') { - this.submittingUsersChange = true; - this.getCompanyMembers(this.company.id); - } else if (arg === 'domain') { - this.getCompanyCustomDomain(this.company.id); - } - }); - } - - getCompanyMembers(companyId: string) { - this._company.fetchCompanyMembers(companyId).subscribe((res) => { - if (this.company.invitations) { - this.company.invitations.map((invitee: any) => { - invitee.email = invitee.invitedUserEmail; - invitee.pending = true; - }); - } else { - this.company.invitations = []; - } - this._user.cast.subscribe((user) => { - this.currentUser = res.find((member) => member.email === user.email); - - if (this.currentUser.role === 'ADMIN') { - this.membersTableDisplayedColumns = ['email', 'name', 'role', 'twoFA', 'active', 'access', 'actions']; - } else { - this.membersTableDisplayedColumns = ['email', 'name', 'role', 'twoFA', 'access']; - } - - const currentMembers = orderBy(res, ['role', 'email']); - if (this.currentUser.role === 'ADMIN') { - const userIndex = currentMembers.findIndex((user) => user.email === this.currentUser.email); - - if (userIndex !== -1) { - const user = currentMembers.splice(userIndex, 1)[0]; - currentMembers.unshift(user); - } - } - - this.members = [...currentMembers, ...this.company.invitations]; - }); - this.adminsCount = res.filter((user) => user.role === 'ADMIN').length; - this.unsuspendedAdminsCount = res.filter((user) => user.role === 'ADMIN' && !user.suspended).length; - this.submittingUsersChange = false; - }); - } - - getCompanyCustomDomain(companyId: string) { - this._company.getCustomDomain(companyId).subscribe((res) => { - if (res.success) { - this.companyCustomDomain = res.domain_info; - this.companyCustomDomainHostname = res.domain_info.hostname; - this.companyCustomDomainThirdLevel = this.companyCustomDomainHostname.split('.')[0]; - } else { - this.companyCustomDomain = { - id: null, - companyId: companyId, - hostname: '', - }; - this.companyCustomDomainHostname = ''; - this.companyCustomDomainPlaceholder = `${this.company.name.toLowerCase().replace(/[^a-z0-9]/g, '')}.example.com`; - this.companyCustomDomainThirdLevel = this.companyCustomDomainPlaceholder.split('.')[0]; - } - }); - } - - handleAddMemberDialogOpen() { - this.dialog.open(InviteMemberDialogComponent, { - width: '25em', - data: this.company, - }); - } - - handleDeleteMemberDialogOpen(user: CompanyMember) { - this.dialog.open(DeleteMemberDialogComponent, { - width: '25em', - data: { companyId: this.company.id, user }, - }); - } - - handleRevokeInvitationDialogOpen(userEmail: string) { - this.dialog.open(RevokeInvitationDialogComponent, { - width: '25em', - data: { companyId: this.company.id, userEmail }, - }); - } - - changeCompanyName() { - this.submittingChangedName = true; - this._company.updateCompanyName(this.company.id, this.company.name).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Company: company name is updated successfully', - }); - posthog.capture('Company: company name is updated successfully'); - this.submittingChangedName = false; - }, - () => (this.submittingChangedName = false), - () => (this.submittingChangedName = false), - ); - } - - updateRole(userId: string, userRole: CompanyMemberRole) { - this.submittingUsersChange = true; - this._company.updateCompanyMemberRole(this.company.id, userId, userRole).subscribe(() => { - this.getCompanyMembers(this.company.id); - this.angulartics2.eventTrack.next({ - action: 'Company: role is updated successfully', - }); - posthog.capture('Company: role is updated successfully'); - }); - } - - switchSuspendance(isSuspendance: boolean, memberEmail: string) { - console.log(isSuspendance, memberEmail); - this.submittingUsersChange = true; - if (isSuspendance) { - this._company.suspendCompanyMember(this.company.id, [memberEmail]).subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Company: member is suspended', - }); - posthog.capture('Company: member is suspended'); - }); - } else { - this._company.restoreCompanyMember(this.company.id, [memberEmail]).subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Company: member is restored', - }); - posthog.capture('Company: member is restored'); - }); - } - } - - handleChangeCompanyDomain() { - this.submittingCustomDomain = true; - if (this.companyCustomDomain.id) { - this._company.updateCustomDomain(this.company.id, this.companyCustomDomain.id).subscribe(() => { - this.submittingCustomDomain = false; - this.angulartics2.eventTrack.next({ - action: 'Company: domain is updated successfully', - }); - posthog.capture('Company: domain is updated successfully'); - }); - } else { - this._company.createCustomDomain(this.company.id, this.companyCustomDomainHostname).subscribe(() => { - this.submittingCustomDomain = false; - this.angulartics2.eventTrack.next({ - action: 'Company: domain is created successfully', - }); - posthog.capture('Company: domain is created successfully'); - }); - } - } - - handleDeleteDomainDialogOpen() { - this.dialog.open(DeleteDomainDialogComponent, { - width: '25em', - data: { companyId: this.company.id, domain: this.companyCustomDomainHostname }, - }); - } -} diff --git a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.css b/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.html b/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.html deleted file mode 100644 index 7659a0cc6..000000000 --- a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.html +++ /dev/null @@ -1,16 +0,0 @@ -

Confirm domain deletion

- -

You are about to remove {{ data.domain }} from your company

-
-

Once deleted, users will no longer be able to access the admin panel via this domain — please notify them in advance.

-
-

Are you sure you want to proceed?

-
- - - - diff --git a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.spec.ts b/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.spec.ts deleted file mode 100644 index 731903753..000000000 --- a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2Module } from 'angulartics2'; -import { DeleteDomainDialogComponent } from './delete-domain-dialog.component'; - -describe('DeleteDomainDialogComponent', () => { - let component: DeleteDomainDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [Angulartics2Module.forRoot(), DeleteDomainDialogComponent], - providers: [ - provideHttpClient(), - { - provide: MAT_DIALOG_DATA, - useValue: { - companyId: '', - domain: '', - }, - }, - { provide: MatDialogRef, useValue: MatDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DeleteDomainDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.ts b/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.ts deleted file mode 100644 index 8aeb75419..000000000 --- a/frontend/src/app/components/company/delete-domain-dialog/delete-domain-dialog.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { CompanyService } from 'src/app/services/company.service'; - -@Component({ - selector: 'app-delete-domain-dialog', - imports: [CommonModule, MatDialogModule, MatButtonModule], - templateUrl: './delete-domain-dialog.component.html', - styleUrl: './delete-domain-dialog.component.css', -}) -export class DeleteDomainDialogComponent { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - public dialogRef: MatDialogRef, - public _company: CompanyService, - private angulartics2: Angulartics2, - ) {} - - deleteCompanyDomain() { - this.submitting = true; - this._company.deleteCustomDomain(this.data.companyId).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Company: domain is deleted successfully', - }); - posthog.capture('Company: domain is deleted successfully'); - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.css b/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.html b/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.html deleted file mode 100644 index 31d433f76..000000000 --- a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.html +++ /dev/null @@ -1,14 +0,0 @@ -

Confirm member delete

- -

You are going to delete {{ data.user.name || data.user.email}} from your company.

-
-

Please confirm.

-
- - - - \ No newline at end of file diff --git a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.spec.ts b/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.spec.ts deleted file mode 100644 index d720160f3..000000000 --- a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Angulartics2Module } from 'angulartics2'; -import { DeleteMemberDialogComponent } from './delete-member-dialog.component'; - -describe('DeleteMemberDialogComponent', () => { - let component: DeleteMemberDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, FormsModule, Angulartics2Module.forRoot(), DeleteMemberDialogComponent], - providers: [ - provideHttpClient(), - { - provide: MAT_DIALOG_DATA, - useValue: { - companyId: '', - user: { - id: '', - email: '', - name: '', - }, - }, - }, - { provide: MatDialogRef, useValue: MatDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DeleteMemberDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.ts b/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.ts deleted file mode 100644 index 7b8aa2607..000000000 --- a/frontend/src/app/components/company/delete-member-dialog/delete-member-dialog.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { CompanyService } from 'src/app/services/company.service'; - -@Component({ - selector: 'app-delete-member-dialog', - templateUrl: './delete-member-dialog.component.html', - styleUrls: ['./delete-member-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule], -}) -export class DeleteMemberDialogComponent { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - public dialogRef: MatDialogRef, - private _company: CompanyService, - private angulartics2: Angulartics2, - ) {} - - deleteCompanyMember() { - this.submitting = true; - this._company - .removeCompanyMemder(this.data.companyId, this.data.user.id, this.data.user.email, this.data.user.name) - .subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Company: member is deleted successfully', - }); - posthog.capture('Company: member is deleted successfully'); - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.css b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.css deleted file mode 100644 index 10f62ab32..000000000 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.css +++ /dev/null @@ -1,43 +0,0 @@ -.add-member-input { - width: 100%; - margin-bottom: 8px; -} - -.role-button { - width: 100%; - margin-bottom: 8px; -} - -.role-button .mdc-button__label { - color: rgba(0, 0, 0, 0.55); - font-weight: 400; -} - -.role-option { - padding-top: 4px; - padding-bottom: 4px; -} - -.role-option-header { - display: flex; - align-items: center; -} - -.role-option-header__mark { - font-size: 14px; - margin-bottom: -10px; - font-weight: 600; - margin-left: 4px; -} - -.role-option__description { - color: rgba(0, 0, 0, 0.64); - font-size: 13px; - margin-top: 2px; -} - -@media (prefers-color-scheme: dark) { - .role-option__description { - color: rgba(255, 255, 255, 0.7); - } -} diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html deleted file mode 100644 index 336d49b10..000000000 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html +++ /dev/null @@ -1,87 +0,0 @@ -

Add member to {{company.name}} company

-
- - - Enter user email - - Email should not be empty. - Invalid email format. - -
- - - - - - -
- - - - - Group - - -- None -- - - - {{group.title}} - - - - -
- - - - - - -
diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.spec.ts b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.spec.ts deleted file mode 100644 index 46b2da222..000000000 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { InviteMemberDialogComponent } from './invite-member-dialog.component'; - -describe('AddMemberDialogComponent', () => { - let component: InviteMemberDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - Angulartics2Module.forRoot(), - InviteMemberDialogComponent, - BrowserAnimationsModule, - ], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: { id: '', connections: [] } }, - { provide: MatDialogRef, useValue: MatDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(InviteMemberDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.stories.ts b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.stories.ts deleted file mode 100644 index e332b9c11..000000000 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.stories.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { of } from 'rxjs'; -import { CompanyService } from 'src/app/services/company.service'; -import { InviteMemberDialogComponent } from './invite-member-dialog.component'; - -const mockCompanyService: Partial = { - inviteCompanyMember: () => of(null as any), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/InviteMember', - component: InviteMemberDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - id: 'company-1', - connections: [ - { - title: 'Production DB', - isTestConnection: false, - groups: [ - { id: 'group-1', title: 'Admins' }, - { id: 'group-2', title: 'Readers' }, - ], - }, - { - title: 'Test DB', - isTestConnection: true, - groups: [{ id: 'group-3', title: 'Testers' }], - }, - ], - }, - }, - { provide: CompanyService, useValue: mockCompanyService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts deleted file mode 100644 index d472a0d5c..000000000 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { NgForOf, NgIf } from '@angular/common'; -import { Component, Inject, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatSelectModule } from '@angular/material/select'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { CompanyMemberRole } from 'src/app/models/company'; -import { CompanyService } from 'src/app/services/company.service'; -import { environment } from 'src/environments/environment'; -import { TurnstileComponent } from '../../ui-components/turnstile/turnstile.component'; - -@Component({ - selector: 'app-invite-member-dialog', - templateUrl: './invite-member-dialog.component.html', - styleUrls: ['./invite-member-dialog.component.css'], - standalone: true, - imports: [ - NgIf, - NgForOf, - FormsModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatButtonModule, - MatMenuModule, - MatIconModule, - EmailValidationDirective, - TurnstileComponent, - ], -}) -export class InviteMemberDialogComponent { - protected posthog = posthog; - @ViewChild(TurnstileComponent) turnstileWidget: TurnstileComponent; - - CompanyMemberRole = CompanyMemberRole; - - public isSaas = (environment as any).saas; - public turnstileToken: string | null = null; - public companyMemberEmail: string; - public companyMemberRole: CompanyMemberRole = CompanyMemberRole.Member; - public submitting: boolean = false; - public companyUsersGroup: string = null; - public groups: { - title: string; - groups: object[]; - }[] = []; - - public companyRolesName = { - ADMIN: 'Account Owner', - DB_ADMIN: 'System Admin', - USER: 'Member', - }; - - constructor( - @Inject(MAT_DIALOG_DATA) public company: any, - public dialogRef: MatDialogRef, - private _company: CompanyService, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.groups = this.company.connections.sort((a, b) => a.isTestConnection - b.isTestConnection); - } - - addCompanyMember() { - this.submitting = true; - this._company - .inviteCompanyMember( - this.company.id, - this.companyUsersGroup, - this.companyMemberEmail, - this.companyMemberRole, - this.isSaas ? this.turnstileToken : null, - ) - .subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Company: member is invited successfully', - }); - posthog.capture('Company: member is invited successfully'); - - this.submitting = false; - this.dialogRef.close(); - }, - () => { - this._resetTurnstile(); - }, - () => { - this.submitting = false; - }, - ); - } - - onTurnstileToken(token: string) { - this.turnstileToken = token; - } - - onTurnstileError() { - this.turnstileToken = null; - } - - onTurnstileExpired() { - this.turnstileToken = null; - } - - private _resetTurnstile(): void { - if (this.isSaas && this.turnstileWidget) { - this.turnstileWidget.reset(); - this.turnstileToken = null; - } - } -} diff --git a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.css b/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.html b/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.html deleted file mode 100644 index 772547d14..000000000 --- a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.html +++ /dev/null @@ -1,14 +0,0 @@ -

Revoke email invitation

- -

You are going to revoke invitation from {{ data.userEmail }} email.

-
-

Please confirm.

-
- - - - \ No newline at end of file diff --git a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.spec.ts b/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.spec.ts deleted file mode 100644 index 30269255c..000000000 --- a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Angulartics2Module } from 'angulartics2'; -import { RevokeInvitationDialogComponent } from './revoke-invitation-dialog.component'; - -describe('RevokeInvitationDialogComponent', () => { - let component: RevokeInvitationDialogComponent; - let fixture: ComponentFixture; - - const _mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, Angulartics2Module.forRoot(), RevokeInvitationDialogComponent], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: { companyId: '', userEmail: '' } }, - { provide: MatDialogRef, useValue: MatDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(RevokeInvitationDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.ts b/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.ts deleted file mode 100644 index 93a8b9a29..000000000 --- a/frontend/src/app/components/company/revoke-invitation-dialog/revoke-invitation-dialog.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { CompanyService } from 'src/app/services/company.service'; - -@Component({ - selector: 'app-revoke-invitation-dialog', - templateUrl: './revoke-invitation-dialog.component.html', - styleUrls: ['./revoke-invitation-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule], -}) -export class RevokeInvitationDialogComponent { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - public dialogRef: MatDialogRef, - private _company: CompanyService, - private angulartics2: Angulartics2, - ) {} - - revokeInvitation() { - this._company.revokeInvitation(this.data.companyId, this.data.userEmail).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Company: invitation is revoked successfully', - }); - posthog.capture('Company: invitation is revoked successfully'); - - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/connect-db/connect-db.component.css b/frontend/src/app/components/connect-db/connect-db.component.css deleted file mode 100644 index b7c73b85c..000000000 --- a/frontend/src/app/components/connect-db/connect-db.component.css +++ /dev/null @@ -1,291 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.connectPage { - margin-top: var(--top-margin); - margin-bottom: 4em; -} - -.form { - display: grid; - grid-template-columns: repeat(4, 1fr); - grid-gap: 0.5em 1.5em; - margin: 0 auto 40px; - width: clamp(18.75em, 96%, 50em); -} - -@media screen and (max-width: 600px) { - .form { - display: flex; - flex-direction: column; - padding: 0 9vw; - width: 100%; - } -} - -:host ::ng-deep .credentials-fieldset { - display: grid; - grid-template-columns: subgrid; - grid-template-rows: subgrid; - grid-column: 1 / span 4; - grid-row: 6 / span 4; -} - -:host ::ng-deep .connectForm__typeSwitch + ndc-dynamic + .credentials-fieldset { - grid-row: 4 / span 4; -} - -@media (width <= 600px) { - :host ::ng-deep .credentials-fieldset { - display: flex; - flex-direction: column; - } -} - -/* :host ::ng-deep .credentials-fieldset-no-warning { - display: grid; - grid-template-columns: subgrid; - grid-template-rows: subgrid; - grid-column: 1 / span 4; - grid-row: 4 / span 4; -} - -@media (width <= 600px) { - :host ::ng-deep .credentials-fieldset-no-warning { - display: flex; - flex-direction: column; - } -} */ - -.mat-h1 { - margin-top: 2vw; -} - -.warningMessage { - display: flex; - align-items: center; - margin-bottom: 8px; - padding: 16px 12px; -} - -@media (prefers-color-scheme: dark) { - .warningMessage { - background: #424242; - } -} - -@media (prefers-color-scheme: light) { - .warningMessage { - background: #f5f5f5; - } -} - -.warningMessage__icon { - flex-shrink: 0; - margin-right: 12px; -} - -.warningMessage .mat-body-1 { - margin-bottom: 0; -} - -.connectForm__ipAlert { - --alert-margin: 0 !important; - margin-bottom: 12px; -} - -.connectForm__title { - grid-column: 1 / span 3; -} - -.connectForm__hostname, -.connectForm__port { - padding-bottom: 2em; -} - -.connectForm__fullLine { - grid-column: 1 / span 4; -} - -.connectForm__toggle { - margin-bottom: 12px; -} - -.connectForm__toggle ::ng-deep .mat-button-toggle-checked { - background-color: rgba(0, 0, 0, 0.08) !important; -} - -.radio-button { - margin-right: 20px; -} - -.form-textarea { - resize: none; -} - -.test-connection-actions { - grid-column: 1 / span 4; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - margin-top: 16px; -} - -.test-connection-message { - color: rgba(0, 0, 0, 0.54); - margin-bottom: 4px; -} - -.actions { - grid-column: 1 / span 4; - display: flex; - align-items: center; - justify-content: flex-end; - margin-top: 12px; -} - -.delete-button { - margin-right: auto; -} - -.test-button { - margin-right: 20px; -} - -.instruction__docker { - margin-top: 32px; - margin-bottom: auto; -} - -.github-button { - margin-bottom: 20px; -} - -.github-button ::ng-deep .mdc-button__label { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; -} - -.github-icon { - margin-right: 8px; - margin-left: -4px; - height: 22px; - width: 22px; -} - -.docker-instruction { - margin-top: 20px; -} - -.instruction-steps { - list-style-position: inside; -} - -.instruction-step { - margin: 1em 0; -} - -.instruction-step__link { - color: var(--color-accentedPalette-500); - text-decoration: none; -} - -.instruction-step__link:hover { - color: #fe5e85; - text-decoration: underline; -} - -.instruction-command { - background: rgba(0, 0, 0, 0.04); - border-radius: 4px; - padding: 8px 12px; -} - -.connectForm__connectionStringContainer { - background-color: var(--info-background-color); - border: 1px solid var(--color-infoPalette-500); - border-radius: 8px; - padding: 16px; - margin-bottom: 16px; -} - -.connectForm__connectionStringHeader { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; - color: var(--color-infoPalette-500); -} - -.connectForm__connectionStringIcon { - font-size: 20px; - width: 20px; - height: 20px; -} - -.connectForm__connectionStringDescription { - margin: 0 0 12px 0; - font-size: 11px; - opacity: 0.6; -} - -.agent-token { - display: flex; - margin-top: 32px; - margin-bottom: 20px; - width: 100%; -} - -.agent-token__copy-button { - margin-top: 4px; - margin-left: 12px; -} - -.connectForm__connectionStringParsed { - display: flex; - align-items: flex-start; - gap: 6px; - margin-top: 8px !important; - color: var(--color-successPalette-500); - font-size: 12px; -} - -.connectForm__connectionStringParsed-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-top: 1px; -} - -.connectForm__connectionStringParsed-label { - font-weight: 500; - margin-right: 4px; -} - -.connectForm__connectionStringParsed-value { - word-break: break-all; - opacity: 0.8; -} - -.connectForm__connectionStringOverrideNote { - display: flex; - align-items: center; - gap: 6px; - margin-top: -4px !important; - margin-bottom: 0 !important; - color: var(--color-infoPalette-500); - font-size: 13px; - font-weight: 500; -} - -.connectForm__connectionStringOverrideNote-icon { - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; -} diff --git a/frontend/src/app/components/connect-db/connect-db.component.html b/frontend/src/app/components/connect-db/connect-db.component.html deleted file mode 100644 index 8074e8ba8..000000000 --- a/frontend/src/app/components/connect-db/connect-db.component.html +++ /dev/null @@ -1,284 +0,0 @@ - - -
-
-

- {{ connectionID ? 'Edit credentials' : 'Connect database' }} -

- - - Connection title - - - - - Database type - - - {{supportedDatabasesTitles[dbType]}} - - - - -
- - Direct connection - Agent connection - -
- -
-
- warning_amber -
- Please make sure our servers are able to access your database.
Rocketadmin uses the IP address - - for all outbound requests. -
-
- - - -
- Please make sure our servers are able to access your database.
Rocketadmin uses the IP address - - for all outbound requests. -
-
-
-
- - @if (db.connectionType === 'direct' && !db.id) { -
-
- link - Quick connect with a connection string -
-

- Paste your database connection URI below to automatically fill in all the credentials fields. The string is not stored — only the parsed values are used. -

- - Connection string - - @if (connectionStringInput.errors?.invalidConnectionStringFormat) { - Invalid format. Expected: scheme://user:password@host:port/database - } - @if (connectionStringInput.errors?.unsupportedScheme) { - Unsupported scheme "{{ connectionStringInput.errors?.unsupportedScheme }}" - } - @if (connectionStringInput.errors?.invalidConnectionString) { - Failed to parse connection string - } - - @if (parsedConnectionString) { -

- check_circle - - Parsed: - {{ parsedConnectionString }} - -

- } - @if (fieldsOverridden) { -

- info - Fields are now the source of truth — paste a new connection string to override them -

- } -
- } - - @if (db.connectionType === 'direct') { - - } - - -
- -
-

- Visit our DockerHub page to install the Rocketadmin agent. -

- - - - - Install Rocketadmin agent - - -

- Once the installation is successfully completed, the new connection will appear in the list of your admin panels. -

- - - Check out the complete guide - -
- -
- - Token - - - Please save this token and use it to configure your agent connection in .config.env file. - - - -
-
- - -
-

You cannot edit test connection.

- - Create your own - - -
- - -
- - Back - - - - - Open dashboard - -
- - -
- - - - - - - - Open dashboard - -
-
-
diff --git a/frontend/src/app/components/connect-db/connect-db.component.spec.ts b/frontend/src/app/components/connect-db/connect-db.component.spec.ts deleted file mode 100644 index 4a1f8b651..000000000 --- a/frontend/src/app/components/connect-db/connect-db.component.spec.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { forwardRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; -import { ConnectionType, DBtype } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { ConnectDBComponent } from './connect-db.component'; -import { DbConnectionConfirmDialogComponent } from './db-connection-confirm-dialog/db-connection-confirm-dialog.component'; -import { DbConnectionDeleteDialogComponent } from './db-connection-delete-dialog/db-connection-delete-dialog.component'; - -describe('ConnectDBComponent', () => { - let component: ConnectDBComponent; - let fixture: ComponentFixture; - let mockMatDialog: { open: ReturnType }; - - const fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - dismissAlert: vi.fn(), - }; - const fakeConnectionsService = { - currentConnection: vi.fn(), - currentConnectionAccessLevel: vi.fn(), - testConnection: vi.fn(), - createConnection: vi.fn(), - updateConnection: vi.fn(), - getCurrentConnectionTitle: vi.fn(), - currentConnectionID: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - canEditConnection: vi.fn().mockReturnValue(true), - }; - - const connectionCredsApp = { - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: '3306', - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: '22', - sshUsername: 'ubuntu', - ssl: false, - cert: null, - connectionType: ConnectionType.Direct, - azure_encryption: false, - signing_key: '', - }; - - beforeEach(async () => { - mockMatDialog = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatSelectModule, - MatRadioModule, - MatInputModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - ConnectDBComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([ - { path: 'dashboard/:id', component: ConnectDBComponent }, - { path: 'auto-configure/:connection-id', component: ConnectDBComponent }, - ]), - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ConnectDBComponent), - multi: true, - }, - { provide: NotificationsService, useValue: fakeNotifications }, - { provide: ConnectionsService, useValue: fakeConnectionsService }, - ], - }) - .overrideComponent(ConnectDBComponent, { - set: { - providers: [{ provide: MatDialog, useFactory: () => mockMatDialog }], - }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ConnectDBComponent); - component = fixture.componentInstance; - - // @ts-expect-error - global.window.fbq = vi.fn(); - global.window.Intercom = vi.fn(); - - fakeConnectionsService.currentConnection.mockReturnValue(connectionCredsApp); - fakeConnectionsService.getCurrentConnectionTitle.mockReturnValue(of('Test connection via SSH tunnel to mySQL')); - // fakeConnectionsService.currentConnectionAccessLevel.mockReturnValue('edit'); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should show Success snackbar if test passes successfully', () => { - fakeConnectionsService.testConnection.mockReturnValue( - of({ - result: true, - }), - ); - - component.testConnection(); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Connection is live'); - - fakeNotifications.showSuccessSnackbar.mockClear(); - }); - - it('should show Error alert if test passes unsuccessfully', () => { - fakeConnectionsService.testConnection.mockReturnValue( - of({ - result: false, - message: 'Error in hostname.', - }), - ); - - component.testConnection(); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith(AlertType.Error, 'Error in hostname.', [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ]); - - fakeNotifications.showAlert.mockClear(); - }); - - it('should set 1521 port for Oracle db type', () => { - component.db.type = DBtype.Oracle; - component.dbTypeChange(); - - expect(component.db.port).toEqual('1521'); - }); - - it('should show Copy message', () => { - component.showCopyNotification('Connection token was copied to clipboard.'); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Connection token was copied to clipboard.'); - - fakeNotifications.showSuccessSnackbar.mockClear(); - }); - - it('should generate password if toggle is enabled', () => { - component.generatePassword(true); - expect(component.masterKey).toBeDefined(); - }); - - it('should open delete connection dialog', () => { - const event = { preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() } as unknown as Event; - - component.confirmDeleteConnection(connectionCredsApp, event); - expect(mockMatDialog.open).toHaveBeenCalledWith(DbConnectionDeleteDialogComponent, { - width: '32em', - data: connectionCredsApp, - }); - }); - - it('should create direct connection', () => { - fakeConnectionsService.createConnection.mockReturnValue(of(connectionCredsApp)); - vi.spyOn(component, 'db', 'get').mockReturnValue(connectionCredsApp); - component.createConnectionRequest(); - - expect(component.connectionID).toEqual('9d5f6d0f-9516-4598-91c4-e4fe6330b4d4'); - }); - - it('should create agent connection and set token', () => { - const dbApp = { - id: null, - title: 'Agent connection', - type: DBtype.Oracle, - port: '5432', - connectionType: ConnectionType.Agent, - } as any; - - const dbRes = { - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - title: 'Agent connection', - type: 'agent_oracle', - port: 5432, - token: '1234-abcd-0987', - }; - - fakeConnectionsService.createConnection.mockReturnValue(of(dbRes)); - vi.spyOn(component, 'db', 'get').mockReturnValue(dbApp); - component.createConnectionRequest(); - - expect(component.connectionID).toEqual('9d5f6d0f-9516-4598-91c4-e4fe6330b4d4'); - expect(component.connectionToken).toEqual('1234-abcd-0987'); - }); - - it('should update direct connection', () => { - fakeConnectionsService.updateConnection.mockReturnValue(of({ connection: connectionCredsApp })); - vi.spyOn(component, 'db', 'get').mockReturnValue(connectionCredsApp); - component.updateConnectionRequest(); - - // expect(routerSpy.navigate).toHaveBeenCalledWith(['/dashboard/9d5f6d0f-9516-4598-91c4-e4fe6330b4d4']); - }); - - it('should update agent connection and set token', () => { - const dbApp = { - id: null, - title: 'Agent connection', - type: DBtype.Oracle, - port: '5432', - connectionType: ConnectionType.Agent, - } as any; - - const dbRes = { - connection: { - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - title: 'Agent connection', - type: 'agent_oracle', - port: 5432, - token: '1234-abcd-0987', - }, - }; - - fakeConnectionsService.updateConnection.mockReturnValue(of(dbRes)); - vi.spyOn(component, 'db', 'get').mockReturnValue(dbApp); - component.updateConnectionRequest(); - - expect(component.connectionToken).toEqual('1234-abcd-0987'); - }); - - it('should open dialog on test error', () => { - vi.spyOn(component, 'db', 'get').mockReturnValue(connectionCredsApp); - component.masterKey = 'master_password_12345678'; - component.handleConnectionError('Hostname is invalid'); - - expect(mockMatDialog.open).toHaveBeenCalledWith(DbConnectionConfirmDialogComponent, { - width: '32em', - data: { - dbCreds: connectionCredsApp, - provider: 'amazon', - masterKey: 'master_password_12345678', - errorMessage: 'Hostname is invalid', - }, - }); - }); - - describe('Connection string functionality', () => { - it('should parse a PostgreSQL connection string and populate db fields', () => { - component.connectionString = 'postgresql://myuser:mypass@db.example.com:5432/mydb'; - component.onConnectionStringChange(null); - - expect(component.db.type).toBe(DBtype.Postgres); - expect(component.db.host).toBe('db.example.com'); - expect(component.db.port).toBe('5432'); - expect(component.db.username).toBe('myuser'); - expect(component.db.password).toBe('mypass'); - expect(component.db.database).toBe('mydb'); - }); - - it('should parse a MySQL connection string and populate db fields', () => { - component.connectionString = 'mysql://root:secret@localhost:3306/app_db'; - component.onConnectionStringChange(null); - - expect(component.db.type).toBe(DBtype.MySQL); - expect(component.db.host).toBe('localhost'); - expect(component.db.port).toBe('3306'); - expect(component.db.username).toBe('root'); - expect(component.db.password).toBe('secret'); - expect(component.db.database).toBe('app_db'); - }); - - it('should parse a MongoDB connection string with authSource option', () => { - component.connectionString = 'mongodb://admin:pass123@mongo.host.com:27017/mydb?authSource=admin'; - component.onConnectionStringChange(null); - - expect(component.db.type).toBe(DBtype.Mongo); - expect(component.db.host).toBe('mongo.host.com'); - expect(component.db.port).toBe('27017'); - expect(component.db.username).toBe('admin'); - expect(component.db.password).toBe('pass123'); - expect(component.db.database).toBe('mydb'); - expect(component.db.authSource).toBe('admin'); - expect(component.autoFilledFields.has('authSource')).toBe(true); - }); - - it('should set autoFilledFields for all parsed fields', () => { - component.connectionString = 'postgresql://user:pass@host:5432/db'; - component.onConnectionStringChange(null); - - expect(component.autoFilledFields.has('host')).toBe(true); - expect(component.autoFilledFields.has('port')).toBe(true); - expect(component.autoFilledFields.has('username')).toBe(true); - expect(component.autoFilledFields.has('password')).toBe(true); - expect(component.autoFilledFields.has('database')).toBe(true); - }); - - it('should set ssl to true when sslmode=require is in connection string', () => { - component.connectionString = 'postgresql://user:pass@host:5432/db?sslmode=require'; - component.onConnectionStringChange(null); - - expect(component.db.ssl).toBe(true); - }); - - it('should set schema when schema option is present', () => { - component.connectionString = 'postgresql://user:pass@host:5432/db?schema=my_schema'; - component.onConnectionStringChange(null); - - expect(component.db.schema).toBe('my_schema'); - expect(component.autoFilledFields.has('schema')).toBe(true); - }); - - it('should reset fieldsOverridden to false after parsing', () => { - component.fieldsOverridden = true; - component.connectionString = 'postgresql://user:pass@host:5432/db'; - component.onConnectionStringChange(null); - - expect(component.fieldsOverridden).toBe(false); - }); - - it('should not modify db fields when connection string is empty', () => { - const originalType = component.db.type; - const originalHost = component.db.host; - - component.connectionString = ' '; - component.onConnectionStringChange(null); - - expect(component.db.type).toBe(originalType); - expect(component.db.host).toBe(originalHost); - }); - - it('should not modify db fields when connection string is invalid', () => { - const originalType = component.db.type; - const originalHost = component.db.host; - - component.connectionString = 'not-a-valid-connection-string'; - component.onConnectionStringChange(null); - - expect(component.db.type).toBe(originalType); - expect(component.db.host).toBe(originalHost); - }); - - it('should use default port when port is not specified in connection string', () => { - component.connectionString = 'postgresql://user:pass@host/db'; - component.onConnectionStringChange(null); - - expect(component.db.port).toBe('5432'); - }); - - it('should handle URL-encoded username and password', () => { - component.connectionString = 'postgresql://my%40user:p%40ss%23word@host:5432/db'; - component.onConnectionStringChange(null); - - expect(component.db.username).toBe('my@user'); - expect(component.db.password).toBe('p@ss#word'); - }); - - it('should clear the connection string after successful parsing', () => { - vi.useFakeTimers(); - component.connectionString = 'postgresql://user:pass@host:5432/db'; - component.onConnectionStringChange(null); - - vi.advanceTimersByTime(300); - expect(component.connectionString).toBe(''); - vi.useRealTimers(); - }); - - it('should store parsed connection string in parsedConnectionString', () => { - const connStr = 'postgresql://user:pass@host:5432/db'; - component.connectionString = connStr; - component.onConnectionStringChange(null); - - expect(component.parsedConnectionString).toBe(connStr); - }); - }); - - describe('clearAutoFilledField', () => { - it('should remove the field from autoFilledFields', () => { - component.autoFilledFields = new Set(['host', 'port', 'username']); - component.clearAutoFilledField('host'); - - expect(component.autoFilledFields.has('host')).toBe(false); - expect(component.autoFilledFields.has('port')).toBe(true); - expect(component.autoFilledFields.has('username')).toBe(true); - }); - - it('should set fieldsOverridden to true', () => { - component.autoFilledFields = new Set(['host']); - component.fieldsOverridden = false; - - component.clearAutoFilledField('host'); - - expect(component.fieldsOverridden).toBe(true); - }); - }); -}); diff --git a/frontend/src/app/components/connect-db/connect-db.component.ts b/frontend/src/app/components/connect-db/connect-db.component.ts deleted file mode 100644 index 4b3edf3e3..000000000 --- a/frontend/src/app/components/connect-db/connect-db.component.ts +++ /dev/null @@ -1,519 +0,0 @@ -import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { Component, NgZone, OnInit, Type } from '@angular/core'; -import { FormsModule, NgForm } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { Router, RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import * as ipaddr from 'ipaddr.js'; -import { DynamicAttributesDirective, DynamicModule } from 'ng-dynamic-component'; -import posthog from 'posthog-js'; -import { take } from 'rxjs'; -import { supportedDatabasesTitles, supportedOrderedDatabases } from 'src/app/consts/databases'; -import googlIPsList from 'src/app/consts/google-IP-addresses'; -import { Alert, AlertActionType, AlertType } from 'src/app/models/alert'; -import { Connection, ConnectionType, DBtype, TestConnection } from 'src/app/models/connection'; -import { AccessLevel } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { UserService } from 'src/app/services/user.service'; -import { environment } from 'src/environments/environment'; -import isIP from 'validator/lib/isIP'; -import { ConnectionStringValidatorDirective } from '../../directives/connection-string-validator.directive'; -import { parseConnectionString } from '../../validators/connection-string.validator'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { IpAddressButtonComponent } from '../ui-components/ip-address-button/ip-address-button.component'; -import { DbConnectionConfirmDialogComponent } from './db-connection-confirm-dialog/db-connection-confirm-dialog.component'; -import { DbConnectionDeleteDialogComponent } from './db-connection-delete-dialog/db-connection-delete-dialog.component'; -import { DbConnectionIpAccessDialogComponent } from './db-connection-ip-access-dialog/db-connection-ip-access-dialog.component'; -import { BaseCredentialsFormComponent } from './db-credentials-forms/base-credentials-form/base-credentials-form.component'; -import { CassandraCredentialsFormComponent } from './db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component'; -import { ClickhouseCredentialsFormComponent } from './db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component'; -import { DynamodbCredentialsFormComponent } from './db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component'; -import { ElasticCredentialsFormComponent } from './db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component'; -import { MongodbCredentialsFormComponent } from './db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component'; -import { MssqlCredentialsFormComponent } from './db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component'; -import { MysqlCredentialsFormComponent } from './db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component'; -import { OracledbCredentialsFormComponent } from './db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component'; -import { PostgresCredentialsFormComponent } from './db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component'; -import { RedisCredentialsFormComponent } from './db-credentials-forms/redis-credentials-form/redis-credentials-form.component'; - -@Component({ - selector: 'app-connect-db', - templateUrl: './connect-db.component.html', - styleUrls: ['./connect-db.component.css'], - imports: [ - MatInputModule, - MatSelectModule, - MatButtonToggleModule, - MatIconModule, - MatButtonModule, - MatTooltipModule, - CdkCopyToClipboard, - RouterModule, - FormsModule, - CommonModule, - MatDialogModule, - MatCheckboxModule, - MatSlideToggleModule, - IpAddressButtonComponent, - AlertComponent, - Angulartics2Module, - ConnectionStringValidatorDirective, - DynamicModule, - DynamicAttributesDirective, - ], -}) -export class ConnectDBComponent implements OnInit { - protected posthog = posthog; - - public isSaas = (environment as any).saas; - public connectionID: string | null = null; - public masterKey: string; - public connectionToken: string | null = null; - public submitting: boolean = false; - public otherOS = []; - public warning: Alert = { - id: 10000000, - type: AlertType.Warning, - message: null, - }; - - public connectionString: string = ''; - public parsedConnectionString: string = ''; - public autoFilledFields: Set = new Set(); - public fieldsOverridden: boolean = false; - - public credentialsFormMap: Record> = { - [DBtype.MySQL]: MysqlCredentialsFormComponent, - [DBtype.Postgres]: PostgresCredentialsFormComponent, - [DBtype.Mongo]: MongodbCredentialsFormComponent, - [DBtype.Dynamo]: DynamodbCredentialsFormComponent, - [DBtype.Cassandra]: CassandraCredentialsFormComponent, - [DBtype.Oracle]: OracledbCredentialsFormComponent, - [DBtype.MSSQL]: MssqlCredentialsFormComponent, - [DBtype.Redis]: RedisCredentialsFormComponent, - [DBtype.Elasticsearch]: ElasticCredentialsFormComponent, - [DBtype.ClickHouse]: ClickhouseCredentialsFormComponent, - }; - - public supportedOrderedDatabases = supportedOrderedDatabases; - public supportedDatabasesTitles = supportedDatabasesTitles; - public ports = { - [DBtype.MySQL]: '3306', - [DBtype.Postgres]: '5432', - [DBtype.Oracle]: '1521', - [DBtype.MSSQL]: '1433', - [DBtype.Mongo]: '27017', - [DBtype.Dynamo]: '', - [DBtype.Cassandra]: '9042', - [DBtype.Redis]: '6379', - [DBtype.Elasticsearch]: '9200', - [DBtype.ClickHouse]: '8443', - }; - - public credentialsFormComponent: Type | null = null; - public credentialsFormInputs: Record = {}; - public credentialsFormOutputs: Record = { - switchToAgent: () => this.switchToAgent(), - masterKeyChange: (key: string) => this.handleMasterKeyChange(key), - fieldChange: (field: string) => this.clearAutoFilledField(field), - }; - public credentialsFormAttributes: Record = { class: 'credentials-fieldset' }; - - constructor( - private _connections: ConnectionsService, - private _notifications: NotificationsService, - public _user: UserService, - private ngZone: NgZone, - public router: Router, - public dialog: MatDialog, - private angulartics2: Angulartics2, - private title: Title, - ) {} - - ngOnInit() { - this.connectionID = this._connections.currentConnectionID; - - const databaseType = this.router.routerState.snapshot.root.queryParams.type; - - if (databaseType) { - this.db.type = databaseType; - this.db.port = this.ports[databaseType]; - } - - this.credentialsFormComponent = this.credentialsFormMap[this.db.type] || null; - - this._connections - .getCurrentConnectionTitle() - .pipe(take(1)) - .subscribe((connectionTitle) => { - if (this.connectionID) { - this.title.setTitle(`Credentials — ${connectionTitle} | Rocketadmin`); - } else { - this.title.setTitle(`Add new database | Rocketadmin`); - } - }); - - if (!this.connectionID) { - this._user.sendUserAction('CONNECTION_CREATION_NOT_FINISHED').subscribe(); - if (this.isSaas) { - // @ts-expect-error - fbq('trackCustom', 'Add_connection'); - } - } - } - - get db(): Connection { - return this._connections.currentConnection; - } - - protected canEditConnection = () => this._connections.canEditConnection(); - - get accessLevel(): AccessLevel { - return this._connections.currentConnectionAccessLevel; - } - - dbTypeChange() { - this.db.port = this.ports[this.db.type]; - this.credentialsFormComponent = this.credentialsFormMap[this.db.type] || null; - } - - testConnection() { - this.submitting = true; - this._connections.testConnection(this.connectionID, this.db).subscribe( - (credsCorrect: TestConnection) => { - this.angulartics2.eventTrack.next({ - action: `Connect DB: manual test connection before ${this.db.id ? 'edit' : 'add'} is ${credsCorrect.result ? 'passed' : 'failed'}`, - properties: { errorMessage: credsCorrect.message }, - }); - posthog.capture( - `Connect DB: manual test connection before ${this.db.id ? 'edit' : 'add'} is ${credsCorrect.result ? 'passed' : 'failed'}`, - { errorMessage: credsCorrect.message }, - ); - if (credsCorrect.result) { - this._notifications.dismissAlert(); - this._notifications.showSuccessSnackbar('Connection is live'); - } else { - this._notifications.showAlert(AlertType.Error, credsCorrect.message, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - } - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - createConnectionRequest() { - this._connections.createConnection(this.db, this.masterKey).subscribe( - (res: any) => { - this.ngZone.run(() => { - const createdConnectionID = res.id!; - if (this.db.connectionType === 'agent') { - this.connectionToken = res.token; - this.connectionID = res.id; - } else { - this.router.navigate([`/auto-configure/${createdConnectionID}`]); - } - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is added successfully', - properties: { connectionType: this.db.connectionType, dbType: this.db.type }, - }); - posthog.capture('Connect DB: connection is added successfully', { - connectionType: this.db.connectionType, - dbType: this.db.type, - }); - }); - }, - (errorMessage) => { - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is added unsuccessfully', - properties: { connectionType: this.db.connectionType, dbType: this.db.type, errorMessage }, - }); - posthog.capture('Connect DB: connection is added unsuccessfully', { - connectionType: this.db.connectionType, - dbType: this.db.type, - errorMessage, - }); - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - updateConnectionRequest() { - this._connections.updateConnection(this.db, this.masterKey).subscribe( - (res: any) => { - this.ngZone.run(() => { - const connectionID = res.connection.id!; - if (this.db.connectionType === 'agent') { - this.connectionToken = res.connection.token; - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is edited successfully', - properties: { connectionType: 'agent' }, - }); - posthog.capture('Connect DB: connection is edited successfully', { connectionType: 'agent' }); - } else { - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is edited successfully', - properties: { connectionType: 'direct' }, - }); - posthog.capture('Connect DB: connection is edited successfully', { connectionType: 'direct' }); - this.router.navigate([`/dashboard/${connectionID}`]); - } - }); - }, - (errorMessage) => { - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is edited unsuccessfully', - properties: { errorMessage }, - }); - posthog.capture('Connect DB: connection is edited unsuccessfully', { errorMessage }); - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - handleConnectionError(errorMessage: string) { - this.dialog.open(DbConnectionConfirmDialogComponent, { - width: '32em', - data: { - dbCreds: this.db, - provider: this.getProvider(), - masterKey: this.masterKey, - errorMessage, - }, - }); - this.submitting = false; - } - - handleCredentialsSubmitting(connectForm: NgForm) { - this.db.masterEncryption = !!this.masterKey; - if (this.db.id) { - this.editConnection(); - } else { - this.createConnection(connectForm); - } - } - - async editConnection() { - this.submitting = true; - let credsCorrect: TestConnection; - - (credsCorrect as any) = await this._connections.testConnection(this.connectionID, this.db).toPromise(); - - this.angulartics2.eventTrack.next({ - action: `Connect DB: automatic test connection on edit is ${credsCorrect?.result ? 'passed' : 'failed'}`, - properties: { errorMessage: credsCorrect?.message }, - }); - posthog.capture(`Connect DB: automatic test connection on edit is ${credsCorrect?.result ? 'passed' : 'failed'}`, { - errorMessage: credsCorrect?.message, - }); - - if (this.db.connectionType === 'agent' || credsCorrect.result) { - this.updateConnectionRequest(); - } else { - this.handleConnectionError(credsCorrect.message); - } - } - - createConnection(_connectForm: NgForm) { - if (this.db.connectionType === 'direct') { - if (this.db.type !== DBtype.Dynamo) { - const ipAddressDilaog = this.dialog.open(DbConnectionIpAccessDialogComponent, { - width: '36em', - data: { - db: this.db, - provider: this.getProvider(), - }, - }); - - ipAddressDilaog.afterClosed().subscribe(async (action) => { - if (action === 'confirmed') { - this.submitting = true; - let credsCorrect: TestConnection = null; - - try { - (credsCorrect as any) = await this._connections.testConnection(this.connectionID, this.db).toPromise(); - - this.angulartics2.eventTrack.next({ - action: `Connect DB: automatic test connection on add is ${credsCorrect.result ? 'passed' : 'failed'}`, - properties: { - connectionType: this.db.connectionType, - dbType: this.db.type, - errorMessage: credsCorrect.message, - }, - }); - posthog.capture( - `Connect DB: automatic test connection on add is ${credsCorrect.result ? 'passed' : 'failed'}`, - { connectionType: this.db.connectionType, dbType: this.db.type, errorMessage: credsCorrect.message }, - ); - - if (credsCorrect?.result) { - this.createConnectionRequest(); - } else { - this.handleConnectionError(credsCorrect.message); - } - } catch (_e) { - credsCorrect = null; - this.submitting = false; - } - } - }); - } else { - this.submitting = true; - let credsCorrect: TestConnection = null; - - this._connections - .testConnection(this.connectionID, this.db) - .toPromise() - .then((res: TestConnection) => { - credsCorrect = res; - this.angulartics2.eventTrack.next({ - action: `Connect DB: automatic test connection on add is ${credsCorrect.result ? 'passed' : 'failed'}`, - properties: { - connectionType: this.db.connectionType, - dbType: this.db.type, - errorMessage: credsCorrect.message, - }, - }); - posthog.capture( - `Connect DB: automatic test connection on add is ${credsCorrect.result ? 'passed' : 'failed'}`, - { - connectionType: this.db.connectionType, - dbType: this.db.type, - errorMessage: credsCorrect.message, - }, - ); - if (credsCorrect?.result) { - this.createConnectionRequest(); - } else { - this.handleConnectionError(credsCorrect.message); - } - }) - .catch((_e) => { - credsCorrect = null; - this.submitting = false; - }); - } - } else { - this.createConnectionRequest(); - } - } - - confirmDeleteConnection(connectionCreds: any, event: Event): void { - event.preventDefault(); - event.stopImmediatePropagation(); - this.dialog.open(DbConnectionDeleteDialogComponent, { - width: '32em', - data: connectionCreds, - }); - } - - generatePassword(checked: boolean) { - if (checked) { - let randomArray = new Uint8Array(32); - window.crypto.getRandomValues(randomArray); - this.masterKey = btoa(String.fromCharCode(...randomArray)); - } - } - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message); - } - - switchToAgent() { - console.log('switchToAgent'); - this.db.connectionType = ConnectionType.Agent; - } - - handleMasterKeyChange(newMasterKey: string): void { - this.masterKey = newMasterKey; - } - - onConnectionStringChange(_input: any) { - if (!this.connectionString.trim()) { - return; - } - - try { - const parsed = parseConnectionString(this.connectionString); - - this.parsedConnectionString = this.connectionString; - this.autoFilledFields = new Set(['host', 'port', 'username', 'password', 'database']); - this.fieldsOverridden = false; - this.db.type = parsed.dbType; - this.db.host = parsed.host; - this.db.port = parsed.port; - this.db.username = parsed.username; - this.db.password = parsed.password; - this.db.database = parsed.database; - - if (parsed.authSource) { - this.db.authSource = parsed.authSource; - this.autoFilledFields.add('authSource'); - } - if (parsed.schema) { - this.db.schema = parsed.schema; - this.autoFilledFields.add('schema'); - } - if (parsed.ssl) { - this.db.ssl = true; - } - - setTimeout(() => (this.connectionString = ''), 300); - } catch (_e) { - // Validation directive handles error display - } - } - - clearAutoFilledField(field: string): void { - this.autoFilledFields = new Set(this.autoFilledFields); - this.autoFilledFields.delete(field); - this.fieldsOverridden = true; - } - - getProvider() { - let provider: string = null; - if (this.db.host.endsWith('.amazonaws.com')) provider = 'amazon'; - if (this.db.host.endsWith('.amazonaws.com') && this.db.type === DBtype.Dynamo) provider = 'amazonDynamoDB'; - if (this.db.host.endsWith('.azure.com')) provider = 'azure'; - if (this.db.host.endsWith('.mongodb.net')) provider = 'mongoatlas'; - if (this.db.host.endsWith('.ondigitalocean.com')) provider = 'digitalocean'; - if (this.db.host.endsWith('.scylla.cloud')) provider = 'scylladbcloud'; - if (this.db.host.endsWith('.supabase.com')) provider = 'supabase'; - if (isIP(this.db.host)) { - const hostIP = ipaddr.parse(this.db.host); - for (const addr of googlIPsList) { - if (hostIP.match(ipaddr.parseCIDR(addr))) { - provider = 'google'; - return; - } - } - } - return provider; - } -} diff --git a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.css b/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.css deleted file mode 100644 index 874b699a4..000000000 --- a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.css +++ /dev/null @@ -1,53 +0,0 @@ -.connection-error-message { - display: block; - color: var(--mat-sidenav-content-text-color); - margin-bottom: 20px; -} - -.docs-notification { - display: block; - background-color: var(--bg-color); - color: var(--mat-sidenav-content-text-color); - margin-bottom: 20px; - padding: 8px 12px; -} - -@media (prefers-color-scheme: dark) { - .docs-notification { - --bg-color: #292929; - } -} - -@media (prefers-color-scheme: light) { - .docs-notification { - --bg-color: #f4f4f4; - } -} - -.docs-link { - color: #ef6c00; - font-weight: 500; - text-decoration: none; -} - -.connection-error__actions { - display: flex; - justify-content: space-between; -} - -.main-actions { - display: flex; - gap: 12px; -} - -.link { - display: inline-block; - color: var(--color-primaryPalette-500); - margin-top: 15px; -} - -@media (prefers-color-scheme: dark) { - .link { - color: var(--color-whitePalette-500); - } -} diff --git a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.html b/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.html deleted file mode 100644 index 15542551e..000000000 --- a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.html +++ /dev/null @@ -1,37 +0,0 @@ -

Connection error

- -

Connection does not exist or credentials are incorrect.

- Error: {{data.errorMessage}} - -
- Need help with {{providerNames[data.provider]}} integration? - - Open docs - -
-
- - - - - - -
- - - - Report a bug - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.spec.ts b/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.spec.ts deleted file mode 100644 index f2137a013..000000000 --- a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter, Router } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DbConnectionConfirmDialogComponent } from './db-connection-confirm-dialog.component'; - -describe('DbConnectionConfirmDialogComponent', () => { - let component: DbConnectionConfirmDialogComponent; - let fixture: ComponentFixture; - - let routerSpy; - let fakeConnectionsService = { - updateConnection: vi.fn(), - createConnection: vi.fn(), - }; - - beforeEach(async (): Promise => { - routerSpy = { navigate: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot(), DbConnectionConfirmDialogComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: MAT_DIALOG_DATA, - useValue: { - dbCreds: { - id: '12345678', - }, - }, - }, - { provide: MatDialogRef, useValue: {} }, - { provide: Router, useValue: routerSpy }, - { - provide: ConnectionsService, - useValue: fakeConnectionsService, - }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbConnectionConfirmDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should redirect on dashboard after connection edited', () => { - fakeConnectionsService.updateConnection.mockReturnValue(of(true)); - component.editConnection(); - - expect(routerSpy.navigate).toHaveBeenCalledWith(['/dashboard/12345678']); - }); - - it('should stop submitting if editing connection completed', () => { - fakeConnectionsService.updateConnection.mockReturnValue(of(false)); - component.editConnection(); - - expect(component.submitting).toBe(false); - }); - - it('should redirect on dashboard after connection added', () => { - fakeConnectionsService.createConnection.mockReturnValue( - of({ - id: '12345678', - }), - ); - component.createConnection(); - - expect(routerSpy.navigate).toHaveBeenCalledWith(['/dashboard/12345678']); - }); - - it('should stop submitting if adding connection completed', () => { - fakeConnectionsService.createConnection.mockReturnValue(of(false)); - component.createConnection(); - - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.ts b/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.ts deleted file mode 100644 index 34037d55b..000000000 --- a/frontend/src/app/components/connect-db/db-connection-confirm-dialog/db-connection-confirm-dialog.component.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { environment } from 'src/environments/environment'; -import { DbConnectionDeleteDialogComponent } from '../db-connection-delete-dialog/db-connection-delete-dialog.component'; - -@Component({ - selector: 'app-db-connection-confirm-dialog', - templateUrl: './db-connection-confirm-dialog.component.html', - styleUrls: ['./db-connection-confirm-dialog.component.css'], - imports: [NgIf, MatDialogModule, MatButtonModule], -}) -export class DbConnectionConfirmDialogComponent implements OnInit { - public isSaas = (environment as any).saas; - public submitting: boolean = false; - public providerNames = { - amazon: 'Amazon Web Services', - azure: 'Microsoft Azure', - google: 'Google Cloud Platform', - mongoatlas: 'MongoDB Atlas', - digitalocean: 'DigitalOcean', - scylladbcloud: 'ScyllaDB Cloud', - amazonDynamoDB: 'Amazon DynamoDB', - supabase: 'Supabase', - }; - public providerDocsLink = { - amazon: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_aws_mysql', - azure: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-manage-firewall-portal', - google: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_google_cloud', - mongoatlas: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_mongo_atlas', - digitalocean: - 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_digitalocean_postgresql', - scylladbcloud: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_scylladb', - amazonDynamoDB: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_aws_dynamodb', - supabase: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_supabase', - }; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _connections: ConnectionsService, - public dialogRef: MatDialogRef, - public router: Router, - ) {} - - ngOnInit(): void {} - - editConnection() { - this._connections.updateConnection(this.data.dbCreds, this.data.masterKey).subscribe( - () => { - this.router.navigate([`/dashboard/${this.data.dbCreds.id}`]); - }, - undefined, - () => { - this.submitting = false; - }, - ); - } - - createConnection() { - this._connections.createConnection(this.data.dbCreds, this.data.masterKey).subscribe( - (res: any) => { - const connectionID = res.id!; - this.router.navigate([`/dashboard/${connectionID}`]); - }, - undefined, - () => { - this.submitting = false; - }, - ); - } - - openIntercome() { - // @ts-expect-error - Intercom('show'); - } -} diff --git a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.css b/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.css deleted file mode 100644 index 716f248ce..000000000 --- a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.css +++ /dev/null @@ -1,20 +0,0 @@ -.mat-mdc-dialog-content { - margin-bottom: -20px; -} - -.radio-group { - display: flex; - flex-direction: column; -} - -.radio-button { - margin: 8px 0; -} - -.message { - width: 100%; -} - -.message textarea { - resize: none; -} diff --git a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.html b/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.html deleted file mode 100644 index 27066c32a..000000000 --- a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.html +++ /dev/null @@ -1,40 +0,0 @@ -

Delete {{ connectionName }} connection

-
- -

- Before you delete the connection, please let us know why. - Every bit of feedback helps! -

-
- - - Missing features I need - - - Technical issues - - - Another product - - - Other - - - - Anything you want to share? (optional) - - -
- - - - -
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.spec.ts b/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.spec.ts deleted file mode 100644 index 6cedc6ad7..000000000 --- a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter, Router } from '@angular/router'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { of, Subject } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DbConnectionDeleteDialogComponent } from './db-connection-delete-dialog.component'; - -describe('DbConnectionDeleteDialogComponent', () => { - let component: DbConnectionDeleteDialogComponent; - let fixture: ComponentFixture; - let routerSpy; - let fakeConnectionsService = { deleteConnection: vi.fn() }; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async (): Promise => { - routerSpy = { navigate: vi.fn(), events: new Subject() }; - - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - FormsModule, - MatRadioModule, - Angulartics2Module.forRoot(), - DbConnectionDeleteDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: Router, useValue: routerSpy }, - { - provide: ConnectionsService, - useValue: fakeConnectionsService, - }, - Angulartics2, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbConnectionDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should close confirmation dilog and redirect to connection list if deleting is successfull', () => { - fakeConnectionsService.deleteConnection.mockReturnValue(of(true)); - vi.spyOn(component.dialogRef, 'close'); - - component.deleteConnection(); - - expect(component.submitting).toBe(false); - expect(component.dialogRef.close).toHaveBeenCalled(); - expect(routerSpy.navigate).toHaveBeenCalledWith(['/connections-list']); - }); - - it('should stop submitting if deleting is completed', () => { - fakeConnectionsService.deleteConnection.mockReturnValue(of(false)); - - component.deleteConnection(); - - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.stories.ts b/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.stories.ts deleted file mode 100644 index 5f2670692..000000000 --- a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.stories.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DbConnectionDeleteDialogComponent } from './db-connection-delete-dialog.component'; - -const mockConnectionsService: Partial = { - deleteConnection: () => of(null as any), -}; - -const mockRouter: Partial = { - navigate: () => Promise.resolve(true), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/DbConnectionDelete', - component: DbConnectionDeleteDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - id: 'conn-1', - title: 'My PostgreSQL Database', - database: 'test_db', - }, - }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: Router, useValue: mockRouter }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.ts b/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.ts deleted file mode 100644 index 077a39e6a..000000000 --- a/frontend/src/app/components/connect-db/db-connection-delete-dialog/db-connection-delete-dialog.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { Router } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { ConnectionsService } from 'src/app/services/connections.service'; - -@Component({ - selector: 'app-db-connection-delete-dialog', - templateUrl: './db-connection-delete-dialog.component.html', - styleUrls: ['./db-connection-delete-dialog.component.css'], - imports: [MatDialogModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatRadioModule], -}) -export class DbConnectionDeleteDialogComponent implements OnInit { - public connectionName: string; - public reason: string; - public message: string; - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _connectionsService: ConnectionsService, - public dialogRef: MatDialogRef, - public router: Router, - private angulartics2: Angulartics2, - ) {} - - ngOnInit() { - this.connectionName = this.data.title || this.data.database; - } - - deleteConnection() { - this.submitting = true; - const metadata = { - reason: this.reason, - message: this.message, - }; - this._connectionsService.deleteConnection(this.data.id, metadata).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Connect DB: connection is deleted', - properties: { - id: this.data.id, - title: this.data.title || null, - reason: this.reason, - message: this.message, - }, - }); - posthog.capture('Connect DB: connection is deleted', { - id: this.data.id, - title: this.data.title || null, - reason: this.reason, - message: this.message, - }); - this.submitting = false; - this.router.navigate([`/connections-list`]); - this.dialogRef.close(); - }, - undefined, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.css b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.css deleted file mode 100644 index 19a18582d..000000000 --- a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.css +++ /dev/null @@ -1,21 +0,0 @@ -.codeSnippet { - display: flex; - align-items: center; - /* justify-content: space-between; */ - margin: 8px 0; -} - -.code { - background: rgba(0, 0, 0, 0.04); - padding: 8px 12px; - border-radius: 4px; - margin-right: 20px; -} - -.code pre { - white-space: break-spaces; -} - -.rules { - padding: 16px; -} diff --git a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html deleted file mode 100644 index b8c0dded4..000000000 --- a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html +++ /dev/null @@ -1,145 +0,0 @@ -

Give access to our IP address

- -

- Please make sure our servers are able to access your database. Rocketadmin uses the IP address for all outbound requests. -

-
-
- Set up AWS's VPC security groups -
    -
  1. Open the Amazon RDS console.
  2. -
  3. - In the navigation pane, choose Databases and open your database overview. - On the tab Connectivity & security find the section Security / VPC security groups. -
  4. -
  5. Open your VPC security group overview and then click Edit inbound rules.
  6. -
  7. - Click Add Rule, and enter the following information:
    - Type — your db type;
    - Source — select Custom and enter - . -
  8. -
  9. Click Save.
  10. -
-
-
- Set up Azure's firewall rules -
    -
  1. Open your database overview and select Networking from the navigation pane.
  2. -
  3. In the Connectivity method you have to have Public access (allowed IP addresses).
  4. -
  5. - Add a rule to the Firewall rules table. Enter any Firewall rule name, - Start IP address: , - End IP address: . -
  6. -
  7. Click Save.
  8. -
-
-
- Set up Google Cloud's authorized networks -
    -
  1. Open your instance overview and select Connections from the navigation pane.
  2. -
  3. - Tick the Public IP checkbox and click Add network in Authorized networks section. -
  4. -
  5. Enter any name and IP address for a New network.
  6. -
  7. Click Done and then Save to update the instance.
  8. -
-
- -
- Give privileges to a user: -
- -
GRANT CONNECT ON DATABASE "{{data.db.database}}" TO "{{data.db.username}}";
-
ALTER DEFAULT PRIVILEGES IN SCHEMA "{{data.db.schema || 'public'}}" GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "{{data.db.username}}";
-
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "public" TO "{{data.db.username}}";
- -
- -
- - Add the following line in file pg_hba.conf (/var/lib/pgsql/data/pg_hba.conf for Linux). - -
- -
host    all    all    18.221.81.73/32    trust
-
- -
-
- -
- Give privileges to user and allow to connect:
- -
- -
GRANT ALL PRIVILEGES ON {{data.db.database}} TO `{{data.db.username}}`@`18.221.81.73` IDENTIFIED BY `<password>` WITH GRANT OPTION;
-
- -
- - and then - -
- -
FLUSH PRIVILEGES;
-
- -
-
- -
-

- Start the SQL PLUS Command Line and connect to the database as user SYSTEM. - Provide the SYSTEM password that you assigned upon installation (Windows) or - configuration (Linux) of Oracle Database XE. Run the following command: -

- -
- -
EXEC DBMS_XDB.SETLISTENERLOCALACCESS(FALSE);
-
- -
-
-
-
- - - - Setup guide - - - diff --git a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.spec.ts b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.spec.ts deleted file mode 100644 index c93aaa12b..000000000 --- a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { DbConnectionIpAccessDialogComponent } from './db-connection-ip-access-dialog.component'; - -describe('DbConnectionIpAccessDialogComponent', () => { - let component: DbConnectionIpAccessDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatDialogModule, MatSnackBarModule, DbConnectionIpAccessDialogComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com' } }, - { provide: MatDialogRef, useValue: {} }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbConnectionIpAccessDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.ts b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.ts deleted file mode 100644 index 178dedc11..000000000 --- a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { NgIf } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Connection } from 'src/app/models/connection'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { IpAddressButtonComponent } from '../../ui-components/ip-address-button/ip-address-button.component'; - -@Component({ - selector: 'app-db-connection-ip-access-dialog', - templateUrl: './db-connection-ip-access-dialog.component.html', - styleUrls: ['./db-connection-ip-access-dialog.component.css'], - standalone: true, - imports: [ - NgIf, - MatDialogModule, - MatButtonModule, - MatIconModule, - MatTooltipModule, - ClipboardModule, - IpAddressButtonComponent, - ], -}) -export class DbConnectionIpAccessDialogComponent implements OnInit { - public provider: string = null; - public providerDocsLink = { - amazon: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_aws_mysql', - azure: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-manage-firewall-portal', - google: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_google_cloud', - mongoatlas: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_mongo_atlas', - digitalocean: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_digitalocean_postgresql', - supabase: 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_supabase', - }; - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { - provider: string; - db: Connection; - }, - private _notifications: NotificationsService, - ) {} - - ngOnInit(): void {} - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message); - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css deleted file mode 100644 index e80bcba87..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css +++ /dev/null @@ -1,81 +0,0 @@ -.credentials-fieldset__1-2-columns { - grid-column: 1 / span 2; -} - -.credentials-fieldset__3-4-columns { - grid-column: 3 / span 2; -} - -.credentials-fieldset__1-3-columns { - grid-column: 1 / span 3; -} - -.credentials-fieldset__1-4-columns { - grid-column: 1 / span 4; -} - -.credentials-fieldset__hint-link { - color: inherit; -} - -.credentials-fieldset__hint-button { - color: inherit; - background: transparent; - border: none; - font-size: inherit; - text-decoration: underline; - cursor: pointer; -} - -.advanced-settings { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-gap: 0.25em 1.5em; -} - -@media screen and (max-width: 600px) { - .advanced-settings { - display: flex; - flex-direction: column; - } -} - -.advanced-settings__fullLine { - grid-column: 1 / span 3; -} - -.checkbox-line { - margin-bottom: 16px; -} - -@media (prefers-color-scheme: light) { - .autofilled ::ng-deep .mdc-notched-outline__leading, - .autofilled ::ng-deep .mdc-notched-outline__notch, - .autofilled ::ng-deep .mdc-notched-outline__trailing { - border-color: var(--color-successPalette-500) !important; - } - - .autofilled ::ng-deep .mat-mdc-floating-label { - color: var(--color-successPalette-700); - } - - .autofilled ::ng-deep .mat-mdc-text-field-wrapper { - background-color: var(--success-background-color); - } -} - -@media (prefers-color-scheme: dark) { - .autofilled ::ng-deep .mdc-notched-outline__leading, - .autofilled ::ng-deep .mdc-notched-outline__notch, - .autofilled ::ng-deep .mdc-notched-outline__trailing { - border-color: var(--color-successDarkPalette-500) !important; - } - - .autofilled ::ng-deep .mat-mdc-floating-label { - color: var(--color-successDarkPalette-300); - } - - .autofilled ::ng-deep .mat-mdc-text-field-wrapper { - background-color: color-mix(in hsl, var(--color-successDarkPalette-500), transparent 90%); - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.html deleted file mode 100644 index d142637e6..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.html +++ /dev/null @@ -1 +0,0 @@ -

base-credentials-form works!

diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.spec.ts deleted file mode 100644 index 37d8a12f0..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BaseCredentialsFormComponent } from './base-credentials-form.component'; - -describe('BaseCredentialsFormComponent', () => { - let component: BaseCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BaseCredentialsFormComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BaseCredentialsFormComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts deleted file mode 100644 index ccb21c467..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; - -import { Connection } from 'src/app/models/connection'; - -@Component({ - selector: 'app-base-credentials-form', - templateUrl: './base-credentials-form.component.html', - styleUrl: './base-credentials-form.component.css', -}) -export class BaseCredentialsFormComponent { - @Input() connection: Connection; - @Input() readonly: boolean; - @Input() submitting: boolean; - @Input() masterKey: string; - @Input() accessLevel: string; - @Input() autoFilledFields: Set = new Set(); - - @Output() switchToAgent = new EventEmitter(); - @Output() masterKeyChange = new EventEmitter(); - @Output() fieldChange = new EventEmitter(); - - public tunnelingServiceLink = 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_pinggy'; - - handleMasterKeyChange(newMasterKey: string): void { - this.masterKeyChange.emit(newMasterKey); - } - - clearAutoFilled(field: string): void { - this.fieldChange.emit(field); - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html deleted file mode 100644 index 685f4eefd..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html +++ /dev/null @@ -1,209 +0,0 @@ - - Hostname - - - - E.g. cassandra.us-west-1.amazonaws.com
- Connections from internal IPs (e.g. localhost) are not supported -
-
- - - To connect a database on internal IP use Pinggy - (how-to) - or for agent connection. - - Hostname is invalid. -
- - - Port - - Port should not be empty. - - - - Username - - Username should not be empty. - - - - Password - - To keep password the same keep this field blank. - Password needed due to hostname/port change. - - - - Database name - - Name should not be empty. - - - - Data center - - - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty. - - - - SSH host - - SSH host should not be empty. - - - - SSH port - - SSH port should not be empty. - - - - SSH username - - SSH username should not be empty. - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty. - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.spec.ts deleted file mode 100644 index 4bb682d39..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { CassandraCredentialsFormComponent } from './cassandra-credentials-form.component'; - -describe('CassandraCredentialsFormComponent', () => { - let component: CassandraCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule, Angulartics2Module.forRoot(), CassandraCredentialsFormComponent], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(CassandraCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.stories.ts deleted file mode 100644 index 1786dc5d1..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CassandraCredentialsFormComponent } from './cassandra-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_keyspace', - title: 'Test Cassandra Connection', - host: 'localhost', - port: '9042', - sid: null, - type: 'cassandra', - username: 'admin', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/Cassandra', - component: CassandraCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.ts deleted file mode 100644 index 6b55e8ee9..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-cassandra-credentials-form', - templateUrl: './cassandra-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './cassandra-credentials-form.component.css', - ], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class CassandraCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.html deleted file mode 100644 index 3189ce85e..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.html +++ /dev/null @@ -1,197 +0,0 @@ - - Hostname - - - E.g. my-test-db.eu-west-2.aws.clickhouse.cloud -
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - - Database Name - - Name should not be empty - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.spec.ts deleted file mode 100644 index 9cd3846d4..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { ClickhouseCredentialsFormComponent } from './clickhouse-credentials-form.component'; - -describe('ClickhouseCredentialsFormComponent', () => { - let component: ClickhouseCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - ClickhouseCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(ClickhouseCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.stories.ts deleted file mode 100644 index 7bfea8e7b..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ClickhouseCredentialsFormComponent } from './clickhouse-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'default', - title: 'Test ClickHouse Connection', - host: 'localhost', - port: '8123', - sid: null, - type: 'clickhouse', - username: 'default', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/ClickHouse', - component: ClickhouseCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.ts deleted file mode 100644 index 532e4ef2c..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/clickhouse-credentials-form/clickhouse-credentials-form.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-clickhouse-credentials-form', - templateUrl: './clickhouse-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './clickhouse-credentials-form.component.css', - ], - standalone: true, - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class ClickhouseCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.css deleted file mode 100644 index 2d36b7d8d..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.css +++ /dev/null @@ -1,9 +0,0 @@ -.connectForm__hostname { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 56px; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.html deleted file mode 100644 index f03ae9d1c..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.html +++ /dev/null @@ -1,78 +0,0 @@ - - Hostname - - - E.g. https://dynamodb.us-east-2.amazonaws.com -
- Select the endpoint matching your database region from the - - AWS DynamoDB endpoints list - -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid - Hostname must start with "https://" -
- - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - - - Advanced settings - - - -
-
- - -
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.spec.ts deleted file mode 100644 index 339c6cb49..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DynamodbCredentialsFormComponent } from './dynamodb-credentials-form.component'; - -describe('DynamodbCredentialsFormComponent', () => { - let component: DynamodbCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, BrowserAnimationsModule, Angulartics2Module.forRoot({}), DynamodbCredentialsFormComponent], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(DynamodbCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.stories.ts deleted file mode 100644 index 7384fa435..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DynamodbCredentialsFormComponent } from './dynamodb-credentials-form.component'; - -const mockConnection = { - id: null, - database: '', - title: 'Test DynamoDB Connection', - host: 'dynamodb.us-east-1.amazonaws.com', - port: '443', - sid: null, - type: 'dynamodb', - username: '', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/DynamoDB', - component: DynamodbCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.ts deleted file mode 100644 index 5df44d8b1..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/dynamodb-credentials-form/dynamodb-credentials-form.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-dynamodb-credentials-form', - templateUrl: './dynamodb-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './dynamodb-credentials-form.component.css', - ], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class DynamodbCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.html deleted file mode 100644 index 0500b8b3a..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.html +++ /dev/null @@ -1,207 +0,0 @@ - - Hostname - - - E.g. cluster-123456.es.europe-west1.gcp.elastic-cloud.com
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - - - Advanced settings - - - -
- - - - - Username - - Username should not be empty - - - - Database name - - Name should not be empty - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.spec.ts deleted file mode 100644 index dbea5a62e..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { ElasticCredentialsFormComponent } from './elastic-credentials-form.component'; - -describe('ElasticCredentialsFormComponent', () => { - let component: ElasticCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - ElasticCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(ElasticCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - // component.connection = { - // "title": "Test connection via SSH tunnel to mySQL", - // "masterEncryption": false, - // "type": DBtype.MySQL, - // "host": "database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com", - // "port": "3306", - // "username": "admin", - // "database": "testDB", - // "schema": null, - // "sid": null, - // "id": "9d5f6d0f-9516-4598-91c4-e4fe6330b4d4", - // "ssh": true, - // "sshHost": "3.134.99.192", - // "sshPort": '22', - // "sshUsername": "ubuntu", - // "ssl": false, - // "cert": null, - // "connectionType": ConnectionType.Direct, - // "azure_encryption": false, - // "signing_key": '' - // }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.stories.ts deleted file mode 100644 index 1dd5444d8..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ElasticCredentialsFormComponent } from './elastic-credentials-form.component'; - -const mockConnection = { - id: null, - database: '', - title: 'Test Elasticsearch Connection', - host: 'localhost', - port: '9200', - sid: null, - type: 'elasticsearch', - username: 'elastic', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/Elasticsearch', - component: ElasticCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.ts deleted file mode 100644 index 64fcd6e29..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/elastic-credentials-form/elastic-credentials-form.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-elastic-credentials-form', - templateUrl: './elastic-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './elastic-credentials-form.component.css', - ], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class ElasticCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html deleted file mode 100644 index 769f4d381..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html +++ /dev/null @@ -1,208 +0,0 @@ - - Hostname - - - E.g. mongodb+srv://my-test-db.8a8grvb.mongoconnection.net
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - Database name - - Name should not be empty - - - - Authentication database - - - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.spec.ts deleted file mode 100644 index 81f0bc669..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { MongodbCredentialsFormComponent } from './mongodb-credentials-form.component'; - -describe('MongodbCredentialsFormComponent', () => { - let component: MongodbCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - MongodbCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(MongodbCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - // component.connection = { - // "title": "Test connection via SSH tunnel to mySQL", - // "masterEncryption": false, - // "type": DBtype.MySQL, - // "host": "database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com", - // "port": "3306", - // "username": "admin", - // "database": "testDB", - // "schema": null, - // "sid": null, - // "id": "9d5f6d0f-9516-4598-91c4-e4fe6330b4d4", - // "ssh": true, - // "sshHost": "3.134.99.192", - // "sshPort": '22', - // "sshUsername": "ubuntu", - // "ssl": false, - // "cert": null, - // "connectionType": ConnectionType.Direct, - // "azure_encryption": false, - // "signing_key": '' - // }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.stories.ts deleted file mode 100644 index 47aa23fd6..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MongodbCredentialsFormComponent } from './mongodb-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_db', - title: 'Test MongoDB Connection', - host: 'localhost', - port: '27017', - sid: null, - type: 'mongodb', - username: 'admin', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/MongoDB', - component: MongodbCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.ts deleted file mode 100644 index 168163db6..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-mongodb-credentials-form', - templateUrl: './mongodb-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './mongodb-credentials-form.component.css', - ], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class MongodbCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.html deleted file mode 100644 index 9450f6131..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.html +++ /dev/null @@ -1,222 +0,0 @@ - - Hostname - - - E.g. my-test-db.cvfuxe8nltiq.azure.com -
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - Database name - - Name should not be empty - - - - Schema - - Name should not be empty - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - - - - Encryption - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.spec.ts deleted file mode 100644 index cd7c35a08..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { MssqlCredentialsFormComponent } from './mssql-credentials-form.component'; - -describe('MssqlCredentialsFormComponent', () => { - let component: MssqlCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - MssqlCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(MssqlCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.stories.ts deleted file mode 100644 index 2bc8dc1de..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MssqlCredentialsFormComponent } from './mssql-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_db', - title: 'Test MSSQL Connection', - host: 'localhost', - port: '1433', - sid: null, - type: 'mssql', - username: 'sa', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: 'dbo', -}; - -const meta: Meta = { - title: 'DB Credentials/MSSQL', - component: MssqlCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.ts deleted file mode 100644 index 0fe301693..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mssql-credentials-form/mssql-credentials-form.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-mssql-credentials-form', - templateUrl: './mssql-credentials-form.component.html', - styleUrls: ['../base-credentials-form/base-credentials-form.component.css', './mssql-credentials-form.component.css'], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class MssqlCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.html deleted file mode 100644 index 3a4b3df68..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.html +++ /dev/null @@ -1,197 +0,0 @@ - - Hostname - - - E.g. my-test-db.cvfuxe8nltiq.us-east-2.rds.amazonaws.com -
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - - Database Name - - Name should not be empty - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.spec.ts deleted file mode 100644 index 8aa5877f4..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { MysqlCredentialsFormComponent } from './mysql-credentials-form.component'; - -describe('MysqlCredentialsFormComponent', () => { - let component: MysqlCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - MysqlCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(MysqlCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.stories.ts deleted file mode 100644 index 12bd833b3..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MysqlCredentialsFormComponent } from './mysql-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_db', - title: 'Test MySQL Connection', - host: 'localhost', - port: '3306', - sid: null, - type: 'mysql', - username: 'admin', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/MySQL', - component: MysqlCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.ts deleted file mode 100644 index 86e8624bf..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mysql-credentials-form/mysql-credentials-form.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-mysql-credentials-form', - templateUrl: './mysql-credentials-form.component.html', - styleUrls: ['../base-credentials-form/base-credentials-form.component.css', './mysql-credentials-form.component.css'], - standalone: true, - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class MysqlCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.html deleted file mode 100644 index 9b5196830..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.html +++ /dev/null @@ -1,221 +0,0 @@ - - Hostname - - - E.g. my-test-db.cvfuxe8nltiq.us-east-2.rds.amazonaws.com -
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - Database Name - - Name should not be empty - - - - Schema - - Name should not be empty - - - - - - Advanced settings - - - -
- - - - - SID - - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.spec.ts deleted file mode 100644 index f5495a15d..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { OracledbCredentialsFormComponent } from './oracledb-credentials-form.component'; - -describe('OracledbCredentialsFormComponent', () => { - let component: OracledbCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - OracledbCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(OracledbCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.stories.ts deleted file mode 100644 index 13022de44..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { OracledbCredentialsFormComponent } from './oracledb-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_db', - title: 'Test OracleDB Connection', - host: 'localhost', - port: '1521', - sid: 'ORCL', - type: 'oracledb', - username: 'admin', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/OracleDB', - component: OracledbCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.ts deleted file mode 100644 index 66cde9b35..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/oracledb-credentials-form/oracledb-credentials-form.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-oracledb-credentials-form', - templateUrl: './oracledb-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './oracledb-credentials-form.component.css', - ], - imports: [ - NgIf, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - FormsModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class OracledbCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html deleted file mode 100644 index ed2371b1a..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html +++ /dev/null @@ -1,211 +0,0 @@ - - Hostname - - - - E.g. my-test-db.cvfuxe8nltiq.us-east-2.rds.amazonaws.com -
- Connections from internal IPs (e.g. localhost) are not supported -
-
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Username - - Username should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - Database Name - - Name should not be empty - - - - Schema - - Name should not be empty - - - - - - Advanced settings - - - -
- - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.spec.ts deleted file mode 100644 index 95b9f2e3d..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { PostgresCredentialsFormComponent } from './postgres-credentials-form.component'; - -describe('PostgresCredentialsFormComponent', () => { - let component: PostgresCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - PostgresCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(PostgresCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.stories.ts deleted file mode 100644 index d880207e4..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PostgresCredentialsFormComponent } from './postgres-credentials-form.component'; - -const mockConnection = { - id: null, - database: 'test_db', - title: 'Test PostgreSQL Connection', - host: 'localhost', - port: '5432', - sid: null, - type: 'postgres', - username: 'admin', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: 'public', -}; - -const meta: Meta = { - title: 'DB Credentials/PostgreSQL', - component: PostgresCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.ts deleted file mode 100644 index 8bbcf1b08..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-postgres-credentials-form', - templateUrl: './postgres-credentials-form.component.html', - styleUrls: [ - '../base-credentials-form/base-credentials-form.component.css', - './postgres-credentials-form.component.css', - ], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class PostgresCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.css deleted file mode 100644 index 6f4923742..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.connectForm__hostname, -.connectForm__port { - padding-bottom: 20px; -} - -@media (width <= 600px) { - .connectForm__hostname { - padding-bottom: 44px; - } - - .connectForm__port { - padding-bottom: 0; - } -} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html deleted file mode 100644 index 53a458f60..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html +++ /dev/null @@ -1,193 +0,0 @@ - - Hostname - - - E.g. redis-12345.c17.us-east-1-2.ec2.cloud.redislabs.com -
- Connections from internal IPs (e.g. localhost) are not supported -
- - - To connect a database to an internal IP, use something like Pinggy - (here's a guide), - or to connect through an agent - - Hostname is invalid -
- - - Port - - Port should not be empty - - - - Password - - To keep password the same keep this field blank - Password needed due to hostname/port change - - - - - - Advanced settings - - - -
- - - - - Username - - Username should not be empty - - - - Database name - - Name should not be empty - - - - Use SSH tunnel - - - - Private SSH key - - Private SSH key should not be empty - - - - SSH host - - SSH host should not be empty - - - - SSH port - - SSH port should not be empty - - - - SSH username - - SSH username should not be empty - - - - Check SSL certificate - - - - SSL certificate - - SSL certificate should not be empty - -
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.spec.ts b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.spec.ts deleted file mode 100644 index 9bc8dbba9..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { RedisCredentialsFormComponent } from './redis-credentials-form.component'; - -describe('RedisCredentialsFormComponent', () => { - let component: RedisCredentialsFormComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatCheckboxModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - RedisCredentialsFormComponent, - ], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(RedisCredentialsFormComponent); - component = fixture.componentInstance; - - component.connection = { - id: '12345678', - } as any; - - // component.connection = { - // "title": "Test connection via SSH tunnel to mySQL", - // "masterEncryption": false, - // "type": DBtype.MySQL, - // "host": "database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com", - // "port": "3306", - // "username": "admin", - // "database": "testDB", - // "schema": null, - // "sid": null, - // "id": "9d5f6d0f-9516-4598-91c4-e4fe6330b4d4", - // "ssh": true, - // "sshHost": "3.134.99.192", - // "sshPort": '22', - // "sshUsername": "ubuntu", - // "ssl": false, - // "cert": null, - // "connectionType": ConnectionType.Direct, - // "azure_encryption": false, - // "signing_key": '' - // }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.stories.ts b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.stories.ts deleted file mode 100644 index 7c8030024..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.stories.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { RedisCredentialsFormComponent } from './redis-credentials-form.component'; - -const mockConnection = { - id: null, - database: '0', - title: 'Test Redis Connection', - host: 'localhost', - port: '6379', - sid: null, - type: 'redis', - username: '', - password: '', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: 'direct', - schema: '', -}; - -const meta: Meta = { - title: 'DB Credentials/Redis', - component: RedisCredentialsFormComponent, - args: { - connection: mockConnection as any, - readonly: false, - submitting: false, - masterKey: '', - accessLevel: 'edit', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Readonly: Story = { - args: { - readonly: true, - }, -}; - -export const Submitting: Story = { - args: { - submitting: true, - }, -}; diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.ts deleted file mode 100644 index 6d24ee576..000000000 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NgIf } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { HostnameValidationDirective } from 'src/app/directives/hostnameValidator.directive'; -import { MasterEncryptionPasswordComponent } from '../../master-encryption-password/master-encryption-password.component'; -import { BaseCredentialsFormComponent } from '../base-credentials-form/base-credentials-form.component'; - -@Component({ - selector: 'app-redis-credentials-form', - templateUrl: './redis-credentials-form.component.html', - styleUrls: ['../base-credentials-form/base-credentials-form.component.css', './redis-credentials-form.component.css'], - imports: [ - NgIf, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatExpansionModule, - HostnameValidationDirective, - MasterEncryptionPasswordComponent, - Angulartics2Module, - ], -}) -export class RedisCredentialsFormComponent extends BaseCredentialsFormComponent { - protected posthog = posthog; -} diff --git a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.css b/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.css deleted file mode 100644 index 4ef99b6cb..000000000 --- a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.css +++ /dev/null @@ -1,58 +0,0 @@ -.encription-setting { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - margin-bottom: 20px; -} - -.encription-password { - display: flex; - flex-direction: column; - margin-top: 32px; - width: 100%; -} - -.encription-password__field { - display: flex; - align-items: center; - width: 100%; -} - -.encription-password__copy-button { - margin-top: -20px; - margin-left: -40px; -} - -.docs-notification { - display: flex; - gap: 8px; - background-color: var(--bg-color); - border-left: 12px solid #ff6f00; - color: var(--mat-sidenav-content-text-color); - margin-top: -8px; - padding: 16px 12px; -} - -@media (prefers-color-scheme: dark) { - .docs-notification { - --bg-color: #292929; - } -} - -@media (prefers-color-scheme: light) { - .docs-notification { - --bg-color: #f4f4f4; - } -} - -.docs-notification__warning { - line-height: 1.55em; - margin-bottom: 8px; -} - -.docs-link { - color: #ef6c00; - font-weight: 500; - text-decoration: none; -} diff --git a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.html b/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.html deleted file mode 100644 index 65ae7d7ae..000000000 --- a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
- - Use client-side encryption - -
-
- - Master password - - - -
- -
-
-

- Rocketadmin will not store your master password. -
- To retain access, store it in a safe place. -
- Share it only with users you want to have access to this connection. -

- - Open docs - -
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.spec.ts b/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.spec.ts deleted file mode 100644 index aced728d6..000000000 --- a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { MasterEncryptionPasswordComponent } from './master-encryption-password.component'; - -describe('MasterEncryptionPasswordComponent', () => { - let component: MasterEncryptionPasswordComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [Angulartics2Module.forRoot({}), MasterEncryptionPasswordComponent], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(MasterEncryptionPasswordComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.ts b/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.ts deleted file mode 100644 index 7b76c8360..000000000 --- a/frontend/src/app/components/connect-db/master-encryption-password/master-encryption-password.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; - -@Component({ - selector: 'app-master-encryption-password', - templateUrl: './master-encryption-password.component.html', - styleUrl: './master-encryption-password.component.css', - imports: [ - CommonModule, - FormsModule, - MatSlideToggleModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatTooltipModule, - ClipboardModule, - Angulartics2Module, - ], -}) -export class MasterEncryptionPasswordComponent implements OnInit { - protected posthog = posthog; - @Input() disabled: boolean; - @Input() masterKey: string; - // @Input() isMasterKeyTurnedOn: boolean; - - @Output() onMasterKeyChange = new EventEmitter(); - // @Output() onMasterKeyToggle = new EventEmitter(); - - public isMasterKeyTurnedOn: boolean = false; - - constructor( - private _connections: ConnectionsService, - private _notifications: NotificationsService, - ) {} - - ngOnInit() { - this.isMasterKeyTurnedOn = this._connections.currentConnection.masterEncryption; - } - - generatePassword(checked: boolean) { - if (checked) { - let randomArray = new Uint8Array(32); - window.crypto.getRandomValues(randomArray); - this.masterKey = btoa(String.fromCharCode(...randomArray)); - - this.onMasterKeyChange.emit(this.masterKey); - } - // this.onMasterKeyToggle.emit(checked); - } - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message); - } -} diff --git a/frontend/src/app/components/connection-settings/connection-settings.component.css b/frontend/src/app/components/connection-settings/connection-settings.component.css deleted file mode 100644 index 3b32833ba..000000000 --- a/frontend/src/app/components/connection-settings/connection-settings.component.css +++ /dev/null @@ -1,210 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.settings-page { - height: 100%; - margin: var(--top-margin) 0; - padding: 0 max(calc(50vw - 325px), 10%); -} - -.mat-h1 { - margin-bottom: 1.5em !important; -} - -.settings-form { - display: flex; - flex-direction: column; - align-items: stretch; - padding-bottom: 72px; -} - -.form-group-ordering { - display: flex; -} - -.form-group-ordering__ordering-by { - width: 50%; -} - -.form-group-ordering__order-options { - margin-top: 24px; - margin-left: 16px; -} - -.form-group-ordering__order-options * { - margin-right: 16px; -} - -.logo-uploader { - display: flex; - align-items: flex-start; - gap: 16px; - width: 100%; -} - -.logo-box { - display: flex; - align-items: center; - justify-content: center; - background: #171d39; - border-radius: 4px; - max-width: 160px; - height: 44px; - object-fit: contain; - padding: 4px 8px; -} - -.logo-img { - height: 40px; - max-width: 136px; - object-fit: contain; -} - -.logo-input { - flex-grow: 1; -} - -.logo-stub { - margin-top: 12px; -} - -.color-theme { - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-gap: 24px; - align-items: center; -} - -.color-item { - position: relative; -} - -.color-input { - width: 100%; -} - -.color-input ::ng-deep input.mat-mdc-input-element { - box-sizing: border-box; - padding-top: 4px; - padding-left: 24px; -} - -.color-sample { - position: absolute; - top: 8px; - left: 8px; - background-color: var(--color); - border-radius: 4px; - height: 24px; - margin-top: 4px; - width: 24px; -} - -.table-name { - display: flex; - flex-direction: column; - gap: 4px; -} - -.table-name__title { - color: black; - font-size: 16px; - margin-bottom: -6px; -} - -@media (prefers-color-scheme: dark) { - .table-name__title { - color: var(--color-whitePalette-500); - } -} - -.table-name__line { - color: rgba(0, 0, 0, 0.6); - font-size: 12px; -} - -@media (prefers-color-scheme: dark) { - .table-name__line { - color: rgba(255, 255, 255, 0.6); - } -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--background-color); - box-shadow: var(--shadow); - height: 64px; - padding: 0 max(calc(50vw - 325px), 10%); - width: 100vw; -} - -@media (prefers-color-scheme: dark) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.5), 0 2px 2px 0 rgba(0, 0, 0, 0.64), 0 1px 5px 0 rgba(0, 0, 0, 0.85); - --background-color: var(--surface-dark-color); - } -} - -@media (prefers-color-scheme: light) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); - --background-color: #fff; - } -} - -.settings-form__back-button { - margin-right: auto; -} - -.settings-form__save-button { - margin-left: 20px; -} - -.settings-banner { - margin: -124px calc(max(calc(50vw - 325px), 10%) * -1) 0; - width: 100vw; -} - -.error-details { - margin-top: 8px; -} - -.audit-toggle { - margin-top: 4px; -} - -@media (width <= 600px) { - .color-theme { - display: flex; - flex-direction: column; - gap: 0; - } - - .color-item { - width: 100%; - min-width: 0; - } - - .color-input { - width: 100%; - min-width: 0; - } - - .color-input ::ng-deep .mat-mdc-text-field-wrapper, - .color-input ::ng-deep .mat-mdc-form-field-flex, - .color-input ::ng-deep .mat-mdc-form-field-infix { - min-width: 0 !important; - width: auto !important; - } - - .color-input ::ng-deep input.mat-mdc-input-element { - padding-left: 24px; - min-width: 0; - } -} diff --git a/frontend/src/app/components/connection-settings/connection-settings.component.html b/frontend/src/app/components/connection-settings/connection-settings.component.html deleted file mode 100644 index 5ad196233..000000000 --- a/frontend/src/app/components/connection-settings/connection-settings.component.html +++ /dev/null @@ -1,165 +0,0 @@ - -
-

Settings for {{connectionName}} connection

- - - - -

Rocketadmin can not find any tables

-

- There are no tables to apply settings. - Please grant Rocketadmin access to required tables or create new table. - You can create a table with SQL editor -

-
- - -
- {{serverError.abstract}} -

{{serverError.details}}

-
- -

{{serverError.abstract}}

-
- -
- -
-
-
-
- project-logo -
- - image - - - Logo URL - - Username should not be empty. - -
- - - -
-
-
- - Primary color (HEX) - - Username should not be empty. - -
- -
-
- - Accented color (HEX) - - Username should not be empty. - -
- -
- - - Hidden tables - - -
- {{table.display_name || table.normalizedTableName}} - ({{table.table}}) -
-
-
-
- - - Table displayed by default - - - -
- {{table.display_name || table.normalizedTableName}} - ({{table.table}}) -
-
-
-
- - - Log changes in tables - -
-
- - -
-
-
diff --git a/frontend/src/app/components/connection-settings/connection-settings.component.spec.ts b/frontend/src/app/components/connection-settings/connection-settings.component.spec.ts deleted file mode 100644 index e0fa08653..000000000 --- a/frontend/src/app/components/connection-settings/connection-settings.component.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { forwardRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionSettings } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { ConnectionSettingsComponent } from './connection-settings.component'; - -describe('ConnectionSettingsComponent', () => { - let component: ConnectionSettingsComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let connectionsService: ConnectionsService; - - const mockTablesList = [ - { - table: 'customer', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - table: 'Orders', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - display_name: 'Created orders', - }, - { - table: 'product', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]; - - const mockConnectionSettings: ConnectionSettings = { - primary_color: '#1F5CB8', - secondary_color: '#F9D648', - logo_url: 'https://www.shutterstock.com/image-vector/abstract-yellow-grunge-texture-isolated-260nw-1981157192.jpg', - company_name: 'Such.Ukr.Lit', - hidden_tables: ['writer_info', 'address'], - tables_audit: false, - default_showing_table: 'customer', - }; - - const mockConnectionSettingsResponse = { - id: '98a20557-6b38-46aa-b09b-d8a716421dd6', - connectionId: '63f804e4-8588-4957-8d7f-655e2309fef7', - ...mockConnectionSettings, - }; - - beforeEach(async () => { - const matSnackBarSpy = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - FormsModule, - MatSelectModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - ConnectionSettingsComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ConnectionSettingsComponent), - multi: true, - }, - { provide: MatSnackBar, useValue: matSnackBarSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ConnectionSettingsComponent); - component = fixture.componentInstance; - tablesService = TestBed.inject(TablesService); - connectionsService = TestBed.inject(ConnectionsService); - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('12345678'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set table list', () => { - const fakeFetchTables = vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(mockTablesList)); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(fakeFetchTables).toHaveBeenCalledWith('12345678', true); - expect(component.tablesList).toEqual([ - { - table: 'customer', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - normalizedTableName: 'Customers', - }, - { - table: 'Orders', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - display_name: 'Created orders', - }, - { - table: 'product', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - normalizedTableName: 'Products', - }, - ]); - }); - - it('should show error if db is empty', () => { - const fakeFetchTables = vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of([])); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(fakeFetchTables).toHaveBeenCalledWith('12345678', true); - expect(component.noTablesError).toBe(true); - }); - - it('should set table settings if they are existed', () => { - const fakeGetSettings = vi - .spyOn(connectionsService, 'getConnectionSettings') - .mockReturnValue(of(mockConnectionSettingsResponse)); - - component.getSettings(); - - expect(fakeGetSettings).toHaveBeenCalledWith('12345678'); - expect(component.connectionSettings).toEqual(mockConnectionSettingsResponse); - expect(component.isSettingsExist).toBe(true); - }); - - it('should set empty settings if they are not existed', () => { - const fakeGetSettings = vi.spyOn(connectionsService, 'getConnectionSettings').mockReturnValue(of(null)); - - component.getSettings(); - - expect(fakeGetSettings).toHaveBeenCalledWith('12345678'); - expect(component.connectionSettings).toEqual({ - hidden_tables: [], - default_showing_table: null, - primary_color: '', - secondary_color: '', - logo_url: '', - company_name: '', - tables_audit: true, - }); - expect(component.isSettingsExist).toBe(false); - }); - - it('should create settings', () => { - component.connectionSettings = mockConnectionSettings; - const fakeCreateSettings = vi.spyOn(connectionsService, 'createConnectionSettings').mockReturnValue(of()); - - component.createSettings(); - - expect(fakeCreateSettings).toHaveBeenCalledWith('12345678', { - primary_color: '#1F5CB8', - secondary_color: '#F9D648', - logo_url: - 'https://www.shutterstock.com/image-vector/abstract-yellow-grunge-texture-isolated-260nw-1981157192.jpg', - company_name: 'Such.Ukr.Lit', - hidden_tables: ['writer_info', 'address'], - default_showing_table: 'customer', - tables_audit: false, - }); - expect(component.submitting).toBe(false); - }); - - it('should update settings', () => { - component.connectionSettings = mockConnectionSettings; - const fakeUpdateSettings = vi.spyOn(connectionsService, 'updateConnectionSettings').mockReturnValue(of()); - - component.updateSettings(); - - expect(fakeUpdateSettings).toHaveBeenCalledWith('12345678', { - primary_color: '#1F5CB8', - secondary_color: '#F9D648', - logo_url: - 'https://www.shutterstock.com/image-vector/abstract-yellow-grunge-texture-isolated-260nw-1981157192.jpg', - company_name: 'Such.Ukr.Lit', - hidden_tables: ['writer_info', 'address'], - default_showing_table: 'customer', - tables_audit: false, - }); - expect(component.submitting).toBe(false); - }); - - it('should reset settings', () => { - const fakeDeleteSettings = vi.spyOn(connectionsService, 'deleteConnectionSettings').mockReturnValue(of()); - - component.resetSettings(); - - expect(fakeDeleteSettings).toHaveBeenCalledWith('12345678'); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/connection-settings/connection-settings.component.ts b/frontend/src/app/components/connection-settings/connection-settings.component.ts deleted file mode 100644 index f1e486cc8..000000000 --- a/frontend/src/app/components/connection-settings/connection-settings.component.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { Title } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import posthog from 'posthog-js'; -import { take } from 'rxjs'; -import { ServerError } from 'src/app/models/alert'; -import { ConnectionSettings } from 'src/app/models/connection'; -import { TableProperties } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { environment } from 'src/environments/environment'; -import { normalizeTableName } from '../../lib/normalize'; -import { PlaceholderConnectionSettingsComponent } from '../skeletons/placeholder-connection-settings/placeholder-connection-settings.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { BannerComponent } from '../ui-components/banner/banner.component'; - -@Component({ - selector: 'app-connection-settings', - templateUrl: './connection-settings.component.html', - styleUrls: ['./connection-settings.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatSlideToggleModule, - MatButtonModule, - MatIconModule, - RouterModule, - BannerComponent, - PlaceholderConnectionSettingsComponent, - AlertComponent, - Angulartics2Module, - ], -}) -export class ConnectionSettingsComponent implements OnInit { - protected posthog = posthog; - public isSaas = (environment as any).saas; - public connectionID: string | null = null; - public tablesList: TableProperties[] = null; - public connectionSettingsInitial: ConnectionSettings = { - hidden_tables: [], - default_showing_table: null, - primary_color: '', - secondary_color: '', - logo_url: '', - company_name: '', - tables_audit: true, - }; - public connectionSettings: ConnectionSettings = { ...this.connectionSettingsInitial }; - // public hiddenTables: string[]; - public loading: boolean = false; - public submitting: boolean = false; - public isSettingsExist: boolean = false; - public noTablesError: boolean = false; - public isServerError: boolean = false; - public serverError: ServerError; - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private title: Title, - @Inject(Angulartics2) private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this._connections - .getCurrentConnectionTitle() - .pipe(take(1)) - .subscribe((connectionTitle) => { - this.title.setTitle(`Settings - ${connectionTitle} | Rocketadmin`); - }); - - this.connectionID = this._connections.currentConnectionID; - - this.loading = true; - - this._tables.fetchTables(this.connectionID, true).subscribe( - (res) => { - if (res.length) { - this.tablesList = res.map((tableItem: TableProperties) => { - if (tableItem.display_name) return { ...tableItem }; - else return { ...tableItem, normalizedTableName: normalizeTableName(tableItem.table) }; - }); - } else { - this.noTablesError = true; - // this.tablesList = res; - } - this.getSettings(); - }, - (err) => { - this.loading = false; - this.isServerError = true; - this.serverError = { abstract: err.error?.message || err.message, details: err.error?.originalMessage }; - }, - ); - } - - get connectionName() { - return this._connections.currentConnection.title || this._connections.currentConnection.database; - } - - protected canEditConnection = () => this._connections.canEditConnection(); - - getSettings() { - this._connections.getConnectionSettings(this.connectionID).subscribe((res: any) => { - if (res) { - this.connectionSettings = { ...res }; - this.isSettingsExist = true; - } else { - this.connectionSettings = { ...this.connectionSettingsInitial }; - this.isSettingsExist = false; - console.log('this.connectionSettings in getSettings else'); - console.log(this.connectionSettings); - } - this.loading = false; - }); - } - - handleSettingsSubmitting() { - if (this.isSettingsExist) { - this.updateSettings(); - } else { - this.createSettings(); - } - } - - createSettings() { - this.submitting = true; - - const updatedSettings = {}; - - for (const [key, value] of Object.entries(this.connectionSettings)) { - if (key === 'hidden_tables') { - updatedSettings[key] = value.length > 0; - } else { - updatedSettings[key] = Boolean(value); - } - } - - this._connections.createConnectionSettings(this.connectionID, this.connectionSettings).subscribe( - () => { - this.getSettings(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Connection settings: settings is created successfully', - properties: updatedSettings, - }); - posthog.capture('Connection settings: settings is created successfully'); - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } - - updateSettings() { - this.submitting = true; - - const updatedSettings = {}; - - for (const [key, value] of Object.entries(this.connectionSettings)) { - if (key === 'hidden_tables') { - updatedSettings[key] = value?.length > 0; - } else { - updatedSettings[key] = Boolean(value); - } - } - - this._connections.updateConnectionSettings(this.connectionID, this.connectionSettings).subscribe( - () => { - this.getSettings(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Connection settings: settings is updated successfully', - properties: updatedSettings, - }); - posthog.capture('Connection settings: settings is updated successfully'); - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } - - resetSettings() { - this.submitting = true; - this._connections.deleteConnectionSettings(this.connectionID).subscribe( - () => { - this.getSettings(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Connection settings: settings is reset successfully', - }); - posthog.capture('Connection settings: settings is reset successfully'); - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } - - onFileSelected(event) { - let reader = new FileReader(); - const file: File = event.target.files[0]; - - reader.addEventListener('load', () => {}, false); - - reader.readAsArrayBuffer(file); - } - - openIntercome() { - // @ts-expect-error - Intercom('show'); - } -} diff --git a/frontend/src/app/components/connections-list/connections-list.component.css b/frontend/src/app/components/connections-list/connections-list.component.css deleted file mode 100644 index ad8aba8ea..000000000 --- a/frontend/src/app/components/connections-list/connections-list.component.css +++ /dev/null @@ -1,91 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.wrapper { - display: flex; - flex-direction: column; - align-items: center; - background-image: - url("../../../assets/bg/connections-list-left-decoration.svg"), - url("../../../assets/bg/connections-list-right-decoration.svg"); - background-repeat: no-repeat, no-repeat; - background-position: - left 125%, - right -26%; - background-size: 17%, 18%; - height: 100%; - padding: 72px max(calc(50vw - 535px), 10%) 72px; -} - -@media (prefers-color-scheme: dark) { - .wrapper { - background-image: - url("../../../assets/bg/connections-list-left-decoration_dark.svg"), - url("../../../assets/bg/connections-list-right-decoration_dark.svg"); - } -} - -@media (width <= 600px) { - .wrapper { - padding: 48px 9vw; - width: 100%; - } -} - -.pageTitleHeader { - position: relative; - text-align: center; - margin-bottom: 54px; - width: 100%; -} - -@media (width <= 600px) { - .pageTitleHeader { - margin-bottom: 32px; - } -} - -.pageTitleHeader .pageTitle { - position: relative; - display: inline-block; -} - -.pageTitleHeader .pageTitle__name { - font-weight: 600; - margin-bottom: 0; -} - -@media (width <= 600px) { - .pageTitleHeader .pageTitle__name { - font-size: 20px !important; - line-height: 1.2 !important; - } -} - -.companyEditLink { - position: absolute; - top: 50%; - right: -48px; - transform: translateY(-50%); - opacity: 0; - transition: opacity 200ms; -} - -@media (width <= 600px) { - .companyEditLink { - opacity: 1; - } -} - -.pageTitle:hover .companyEditLink { - opacity: 1; -} - -.home { - display: contents; -} - -.placeholder { - width: 100%; -} diff --git a/frontend/src/app/components/connections-list/connections-list.component.html b/frontend/src/app/components/connections-list/connections-list.component.html deleted file mode 100644 index f5dc4407f..000000000 --- a/frontend/src/app/components/connections-list/connections-list.component.html +++ /dev/null @@ -1,30 +0,0 @@ - -
-
-
-

{{companyName}}

- - edit - -
- -
- -
-
-
-
- -
- - -
- - - -
diff --git a/frontend/src/app/components/connections-list/connections-list.component.spec.ts b/frontend/src/app/components/connections-list/connections-list.component.spec.ts deleted file mode 100644 index ec316e3bc..000000000 --- a/frontend/src/app/components/connections-list/connections-list.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { ConnectionsListComponent } from './connections-list.component'; - -describe('ConnectionsListComponent', () => { - let component: ConnectionsListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot({}), ConnectionsListComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: ConnectionsService, - // useClass: ConnectionsServiseStub - }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ConnectionsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connections-list/connections-list.component.ts b/frontend/src/app/components/connections-list/connections-list.component.ts deleted file mode 100644 index 6173570b4..000000000 --- a/frontend/src/app/components/connections-list/connections-list.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { Title } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { Connection } from 'src/app/models/connection'; -import { User } from 'src/app/models/user'; -import { CompanyService } from 'src/app/services/company.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UserService } from 'src/app/services/user.service'; -import { PlaceholderConnectionsComponent } from '../skeletons/placeholder-connections/placeholder-connections.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { OwnConnectionsComponent } from './own-connections/own-connections.component'; - -@Component({ - selector: 'app-connections-list', - templateUrl: './connections-list.component.html', - styleUrls: ['./connections-list.component.css'], - imports: [ - CommonModule, - RouterModule, - MatIconModule, - MatButtonModule, - AlertComponent, - PlaceholderConnectionsComponent, - Angulartics2Module, - OwnConnectionsComponent, - ], -}) -export class ConnectionsListComponent implements OnInit { - public connections: Connection[] = null; - public titles: Object; - public displayedCardCount: number = 3; - public connectionsListCollapsed: boolean = true; - public companyName: string; - public currentUser: User; - - constructor( - private _connectionsServise: ConnectionsService, - public deleteDialog: MatDialog, - private _userService: UserService, - private _companyService: CompanyService, - private title: Title, - ) {} - - get ownConnections() { - return this._connectionsServise.ownConnectionsList; - } - - ngOnInit(): void { - this.title.setTitle('Connections | Rocketadmin'); - - this._userService.cast.subscribe((user) => { - this.currentUser = user; - user.id && - this._companyService.fetchCompanyName(user.company.id).subscribe((res: any) => { - this.companyName = res.name; - }); - }); - } -} diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.css b/frontend/src/app/components/connections-list/own-connections/own-connections.component.css deleted file mode 100644 index b45fb7526..000000000 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.css +++ /dev/null @@ -1,432 +0,0 @@ -:host { - flex-grow: 1; - display: flex; - flex-direction: column; - width: 100%; -} - -/* ── Page heading: shrink on mobile to match company name ── */ - -@media (width <= 600px) { - :host h1.mat-display-1 { - font-size: 22px !important; - line-height: 1.2 !important; - margin-bottom: 20px; - } -} - -/* ── Section labels ── */ - -.section { - width: 100%; - margin-top: 32px; -} - -.section:first-of-type { - margin-top: 0; -} - -.section__label { - display: block; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - opacity: 0.45; - margin-bottom: 12px; - text-transform: uppercase; -} - -/* ── DB grid (5 columns) ── */ - -.dbGrid { - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); - align-items: stretch; - grid-gap: 20px; - width: 100%; - padding: 0; - margin: 0; -} - -@media (width <= 600px) { - .dbGrid { - width: 100%; - grid-template-columns: 1fr; - grid-gap: 8px; - } -} - -.dbCard { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - border-radius: 8px; - padding: 8px; - height: 100%; - min-height: 90px; - text-decoration: none; - color: inherit; - font-weight: 600; - cursor: pointer; - transition: box-shadow 200ms, background 200ms, border 200ms; - box-shadow: - 0px 1px 3px 0px rgba(0, 0, 0, 0.2), - 0px 2px 2px 0px rgba(0, 0, 0, 0.12), - 0px 0px 2px 0px rgba(0, 0, 0, 0.14); -} - -@media (width <= 600px) { - .dbCard { - flex-direction: row; - justify-content: flex-start; - gap: 12px; - padding: 10px 14px; - min-height: 54px; - } -} - -.dbCard:hover { - box-shadow: - 0px 1px 5px 0px rgba(0, 0, 0, 0.2), - 0px 3px 4px 0px rgba(0, 0, 0, 0.12), - 0px 2px 4px 0px rgba(0, 0, 0, 0.14); -} - -@media (prefers-color-scheme: dark) { - .dbCard { - background: #2a2a2a; - border: 1px solid #2a2a2a; - } - - .dbCard:hover { - background: var(--surface-dark-color); - border: 1px solid rgba(255, 255, 255, 0.25); - box-shadow: - 0px 1px 3px 0px rgba(255, 255, 255, 0.1), - 0px 2px 2px 0px rgba(255, 255, 255, 0.08), - 0px 0px 2px 0px rgba(255, 255, 255, 0.12); - } -} - -.dbCard__iconBox { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - height: 32px; - width: 32px; -} - -.dbCard__icon { - flex-shrink: 0; - height: 28px; - width: 28px; -} - -@media (prefers-color-scheme: dark) { - .dbCard__icon[data-mat-icon-name="mongodb"], - .dbCard__icon[data-mat-icon-name="mysql"], - .dbCard__icon[data-mat-icon-name="dynamodb"], - .dbCard__icon[data-mat-icon-name="mssql"] { - filter: brightness(2); - } -} - -.dbCard__label { - text-align: center; - font-size: 13px; -} - -.dbCard__subLabel { - display: block; - font-weight: 400; - opacity: 0.5; - font-size: 11px; - margin-top: -2px; -} - -@media (width <= 600px) { - .dbCard__label { - text-align: left; - line-height: 1.2; - flex: 1; - min-width: 0; - } - - .dbCard__subLabel { - margin-top: 2px; - } -} - -/* ── Existing connections list ── */ - -.connections { - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, calc(33.33% - 14px))); - justify-content: center; - gap: 20px; - margin-bottom: 8px; - width: 100%; - padding: 0; -} - -.connectionItem { - min-width: 0; -} - -.connection { - display: flex; - flex-direction: column; - gap: 10px; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - color: inherit; - padding: 12px; - text-decoration: none; - width: 100%; - transition: border 200ms, box-shadow 200ms; -} - -@media (prefers-color-scheme: dark) { - .connection { - background-color: #000; - border: 1px solid #313131; - } -} - -.connection:has(.connectionLogoPreview:hover) { - border: 1px solid var(--color-primaryPalette-500); - box-shadow: - 0px 4px 5px 0px rgba(0, 0, 0, 0.2), - 0px 3px 14px 0px rgba(0, 0, 0, 0.12), - 0px 8px 10px 0px rgba(0, 0, 0, 0.14); -} - -@media (prefers-color-scheme: dark) { - .connection:has(.connectionLogoPreview:hover) { - border: 1px solid #636363; - } -} - -.connectionLogoPreview { - flex-shrink: 0; - display: flex; - align-items: center; - min-width: 0; - gap: 12px; - background-color: var(--color-primaryPalette-500); - border-radius: 2px; - color: #fff; - font-size: 20px; - font-weight: 900; - height: 72px; - overflow: hidden; - padding: 8px; -} - -.connectionLogoPreview__logo { - height: 85%; - object-fit: contain; -} - -.connectionInfo { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; -} - -.connectionInfo .connectionInfo__connectionTitle { - margin-top: -4px; - margin-bottom: -2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -} - -/* ── Compact mobile layout (keep desktop dark banner, just shorter) ── */ - -@media (width <= 600px) { - .connections { - grid-template-columns: 1fr; - gap: 8px; - } - - .connection { - gap: 4px; - padding: 8px; - } - - .connection:has(.connectionSchemaButton) { - padding: 8px 8px 4px; - } - - .connectionLogoPreview { - height: 56px; - padding: 6px 10px; - gap: 10px; - } - - .connectionInfo .connectionInfo__connectionTitle { - font-size: 14px !important; - margin-bottom: -8px; - } - - .connectionLogoPreview .mat-body-2 { - font-size: 11px; - } -} - -/* ── Schema button row at the bottom of the card ── */ - -.connectionSchemaButton { - position: relative; - align-self: stretch; - justify-content: center; - color: var(--mdc-text-button-label-text-color) !important; - font-size: 13px; - font-weight: 500; - line-height: 1; - height: 34px; - transition: color 150ms ease, background-color 150ms ease; -} - -.connectionSchemaButton mat-icon { - font-size: 16px !important; - width: 16px !important; - height: 16px !important; - margin-right: 6px; - transition: color 150ms ease; -} - -.connectionSchemaButton__ai { - color: var(--color-accentedPalette-500) !important; -} - -.connectionSchemaButton__arrow { - position: absolute; - right: 12px; - top: 50%; - margin: 0 !important; - transform: translate(-4px, -50%); - opacity: 0; - transition: opacity 150ms ease, transform 150ms ease; -} - -.connectionSchemaButton:hover { - color: var(--color-accentedPalette-500) !important; -} - -@media (prefers-color-scheme: dark) { - .connectionSchemaButton:hover { - color: var(--color-accentedPalette-100) !important; - background: var(--color-accentedPalette-900); - } - - .connectionSchemaButton:hover .connectionSchemaButton__ai { - color: var(--color-accentedPalette-100) !important; - } -} - -.connectionSchemaButton:hover .connectionSchemaButton__arrow { - opacity: 1; - transform: translate(0, -50%); -} - - - - -/* ── Show more / less ── */ - -.showAllButton { - margin-top: 12px; - margin-bottom: 8px; -} - -/* ── FAB actions ── */ - -.fabActions { - display: flex; - align-items: center; - gap: 12px; - align-self: flex-end; - margin-top: 52px; -} - -/* Desktop and mobile button variants */ -.fabActions--mobile { - display: none; -} - -@media (width <= 600px) { - .fabActions--desktop { - display: none; - } - - .fabActions--mobile { - display: flex; - } -} - -.fabAddButton { - border-radius: 24px; - box-shadow: - 0px 1px 10px 0px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.12), - 0px 2px 4px 0px rgba(0, 0, 0, 0.14); - height: 48px !important; - min-width: 178px; - padding: 0 14px 0 16px !important; -} - -.fabAddButton .mat-icon[iconPositionEnd] { - margin-left: 4px; -} - -@media (width <= 600px) { - .fabActions { - position: fixed; - right: 9vw; - bottom: max(64px, 10vw); - flex-direction: column; - align-items: flex-end; - z-index: 1; - } - - .fabAddButton { - width: auto; - min-width: 178px; - } -} - -/* ── Empty state bottom ── */ - -.empty-state_bottom { - margin-top: auto; -} - -@media (prefers-color-scheme: light) { - .empty-state_bottom { - color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .empty-state_bottom { - color: rgba(255, 255, 255, 0.64); - } -} - -.empty-state__text { - font-size: 12px !important; - font-weight: 500 !important; - margin-bottom: 0 !important; - text-align: center; - text-transform: uppercase; -} diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html b/frontend/src/app/components/connections-list/own-connections/own-connections.component.html deleted file mode 100644 index 607017e0f..000000000 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html +++ /dev/null @@ -1,126 +0,0 @@ - -@if (!connections?.length && connections !== null) { - @if (currentUser?.role === 'ADMIN' || currentUser?.role === 'DB_ADMIN') { -

Add your first connection

- } -} - - -@if (connections?.length) { - - - @if ((connections?.length ?? 0) > displayedCardCount) { - - } - @if (displayedCardCount > 3) { - - } -} @else { - -
- - -
-} - - -@if (connections?.length) { - -
- @if (currentUser?.role === 'ADMIN' || currentUser?.role === 'DB_ADMIN') { - - add - Add database - - } -
- - -
- @if (currentUser?.role === 'ADMIN' || currentUser?.role === 'DB_ADMIN') { - - add - Add connection - - } -
-} - -@if (!connections?.length && connections !== null && hasMultipleMembers) { -
-

- Invited to a company but don't see your admin panel? -

-

- Request access from your Account Owner -

-
-} diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts b/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts deleted file mode 100644 index 516d62ca5..000000000 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; -import { CompanyService } from 'src/app/services/company.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { OwnConnectionsComponent } from './own-connections.component'; - -describe('OwnConnectionsComponent', () => { - let component: OwnConnectionsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [OwnConnectionsComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: UiSettingsService, - useValue: { - isDarkMode: false, - getUiSettings: vi.fn().mockReturnValue(of({ globalSettings: { connectionsListCollapsed: true } })), - updateGlobalSetting: vi.fn(), - }, - }, - { - provide: CompanyService, - useValue: { - fetchCompanyMembers: vi.fn().mockReturnValue(of([])), - }, - }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(OwnConnectionsComponent); - component = fixture.componentInstance; - component.connections = []; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts b/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts deleted file mode 100644 index 9077e8e0c..000000000 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; -import posthog from 'posthog-js'; -import { supportedDatabasesTitles, supportedOrderedDatabases } from 'src/app/consts/databases'; -import { CompanyMember } from 'src/app/models/company'; -import { ConnectionItem, DBtype } from 'src/app/models/connection'; -import { UiSettings } from 'src/app/models/ui-settings'; -import { User } from 'src/app/models/user'; -import { CompanyService } from 'src/app/services/company.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; - -@Component({ - selector: 'app-own-connections', - imports: [CommonModule, RouterModule, MatIconModule, MatButtonModule], - templateUrl: './own-connections.component.html', - styleUrl: './own-connections.component.css', -}) -export class OwnConnectionsComponent implements OnInit, OnChanges { - protected posthog = posthog; - @Input() currentUser: User; - @Input() connections: ConnectionItem[] = null; - @Input() companyId: string; - - public displayedCardCount: number = 3; - public connectionsListCollapsed: boolean; - public supportedDatabasesTitles = supportedDatabasesTitles; - public supportedOrderedDatabases = supportedOrderedDatabases; - public hasMultipleMembers: boolean = false; - public isDarkMode: boolean = false; - - constructor( - private _uiSettings: UiSettingsService, - private _companyService: CompanyService, - ) {} - - ngOnInit() { - this.isDarkMode = this._uiSettings.isDarkMode; - - this._uiSettings.getUiSettings().subscribe((settings: UiSettings) => { - this.connectionsListCollapsed = settings?.globalSettings?.connectionsListCollapsed; - this.displayedCardCount = this.connectionsListCollapsed ? 3 : this.connections?.length || 3; - }); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes.companyId && this.companyId) { - this._companyService.fetchCompanyMembers(this.companyId).subscribe((members: CompanyMember[]) => { - this.hasMultipleMembers = members && members.length > 1; - }); - } - - if (changes.connections && this.connections && !this.connectionsListCollapsed) { - this.displayedCardCount = this.connections.length; - } - } - - showMore() { - this.displayedCardCount = this.connections.length; - this._uiSettings.updateGlobalSetting('connectionsListCollapsed', false); - } - - showLess() { - this.displayedCardCount = 3; - this._uiSettings.updateGlobalSetting('connectionsListCollapsed', true); - } - - supportsSchemaEditing(type: DBtype | string): boolean { - return ( - type === DBtype.Postgres || - type === DBtype.MySQL || - type === DBtype.Oracle || - type === DBtype.MSSQL || - type === DBtype.ClickHouse - ); - } - - getMainTitle(database: string): string { - const title = this.supportedDatabasesTitles[database] || database; - const match = title.match(/^([^(]+)/); - return match ? match[1].trim() : title; - } - - getSubTitle(database: string): string { - const title = this.supportedDatabasesTitles[database] || database; - const match = title.match(/(\([^)]+\))/); - return match ? match[1] : ''; - } -} diff --git a/frontend/src/app/components/dashboard/dashboard.component.css b/frontend/src/app/components/dashboard/dashboard.component.css deleted file mode 100644 index 85b80f884..000000000 --- a/frontend/src/app/components/dashboard/dashboard.component.css +++ /dev/null @@ -1,317 +0,0 @@ -.mat-sidenav-container { - height: 100%; - /* height: calc(100vh - 56px); */ - overflow-x: hidden !important; -} - -.table-list-sidenav { - max-width: 240px; -} - -@media (prefers-color-scheme: dark) { - .table-list-sidenav { - background-color: var(--surface-dark-color); - } -} - -@media (width <= 600px) { - .table-list-sidenav { - display: none; - } -} - -.side-bar_collapsed { - position: static; - float: left; - display: block; - height: 100%; - width: 65px; - visibility: visible !important; - transform: translate3d(0, 0, 0); -} - -@media (width <= 600px) { - .side-bar_collapsed { - display: none; - } -} - -.table-list-sidenav-container ::ng-deep .mat-drawer-inner-container, -.side-bar_collapsed ::ng-deep .mat-drawer-inner-container { - overflow-y: auto !important; - overflow-x: hidden !important; -} - -.table-list-sidenav-container - ::ng-deep - .mat-drawer:not(.mat-drawer-opened):not(.mat-drawer-animating) - .mat-drawer-inner-container { - display: block; -} - -.mat-list-item { - text-decoration: none; -} - -.toggle-button { - margin-top: 8px; - margin-left: 12px; -} - -.toggle-button-position button { - border-radius: 4px; -} - -.toggle-button-position button mat-icon { - border-radius: 4px; -} - -.toggle-button-position button:hover { - border-radius: 4px; -} - -.toggle-button-position button:hover mat-icon { - border-radius: 4px; -} - -.dashboard { - display: flex; - flex-direction: column; - height: calc(100vh - 60px - 56px); - max-width: 1000px; - margin: 20px auto; - widows: 96%; -} - -.title { - align-self: center; -} - -.alerts { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 24px; - margin-bottom: 16px; -} - -.alerts app-alert { - position: initial; -} - -.alerts app-alert:empty { - display: none; -} - -.alerts:has(app-alert:only-child:empty) { - margin: 0; -} - -.ai-config-alert { - display: flex; - gap: 8px; - background-color: color-mix(in hsl, var(--color-accentedPalette-500), transparent 95%); - backdrop-filter: blur(4px); - border-top: 1px solid var(--color-accentedPalette-500); - border-right: 1px solid var(--color-accentedPalette-500); - border-bottom: 1px solid var(--color-accentedPalette-500); - border-left: 12px solid var(--color-accentedPalette-500); - border-radius: 4px; - padding: 12px 16px 12px 8px; -} - -@media (prefers-color-scheme: dark) { - .ai-config-alert { - backdrop-filter: blur(2px); - } -} - -@media (width <= 600px) { - .ai-config-alert { - display: grid; - grid-template-columns: auto 1fr; - } -} - -.ai-config-alert__icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - color: var(--color-accentedPalette-500); - font-size: 24px; - height: 36px; - opacity: 0.9; - width: 36px; -} - -.ai-config-alert__message { - flex: 1; - display: flex; - justify-content: center; - flex-direction: column; - min-height: 36px; - margin: 0 !important; -} - -.ai-config-alert__title { - color: var(--color-accentedPalette-500); - margin-bottom: 4px; -} - -@media (prefers-color-scheme: dark) { - .ai-config-alert__title { - color: inherit; - } -} - -.ai-config-alert__text { - margin: 0 !important; -} - -.ai-config-alert__actions { - margin-left: auto; -} - -@media (width <= 600px) { - .ai-config-alert__actions { - grid-column: 1 / -1; - } -} - -/* ── Status banner ── */ - -.status-banner { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 20px; - width: 100%; - border: none; - border-left: 3px solid var(--color-accentedPalette-500); - border-radius: 0; - background: var(--color-accentedPalette-50); - font-size: 13px; -} - -.status-banner__icon { - flex-shrink: 0; - color: var(--color-accentedPalette-500); - font-size: 20px; - width: 20px; - height: 20px; -} - -.status-banner__body { - flex: 1; - display: flex; - align-items: baseline; - gap: 6px; - min-width: 0; -} - -.status-banner__title { - color: var(--color-accentedPalette-900); - white-space: nowrap; -} - -.status-banner__subtitle { - color: var(--color-accentedPalette-700); -} - -.status-banner__link { - color: var(--color-accentedPalette-500); - text-decoration: underline; -} - -.status-banner__link:hover { - color: var(--color-accentedPalette-700); -} - -.status-banner__progress { - flex-shrink: 0; - width: 80px; - height: 3px; - border-radius: 2px; - background: var(--color-accentedPalette-100); - overflow: hidden; -} - -.status-banner__progress-fill { - width: 40%; - height: 100%; - border-radius: 2px; - background: var(--color-accentedPalette-500); - animation: banner-indeterminate 1.5s ease-in-out infinite; -} - -@keyframes banner-indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(350%); } -} - -@media (prefers-color-scheme: dark) { - .status-banner { - background: var(--color-accentedPalette-900); - } - - .status-banner__title { - color: var(--color-accentedPalette-100); - } - - .status-banner__subtitle { - color: var(--color-accentedPalette-200); - } - - .status-banner__link { - color: var(--color-accentedPalette-500); - } - - .status-banner__link:hover { - color: var(--color-accentedPalette-200); - } - - .status-banner__icon { - color: var(--color-accentedPalette-500); - } - - .status-banner__progress { - background: var(--color-accentedPalette-800); - } - - .status-banner__progress-fill { - background: var(--color-accentedPalette-300); - } -} - -@media (width <= 600px) { - .status-banner__body { - flex-direction: column; - gap: 2px; - } -} - -.error-details { - margin-top: 8px; -} - -.table-preview { - display: flex; - overflow-x: hidden; - height: calc(100vh - 44px); -} - -.table-preview-content { - flex-grow: 1; - padding-bottom: 24px; - height: calc(100vh - 44px); - padding: 0 24px; - overflow-y: scroll; - width: 100%; -} - -.sidenav-content__toggle-button { - display: none !important; -} - diff --git a/frontend/src/app/components/dashboard/dashboard.component.html b/frontend/src/app/components/dashboard/dashboard.component.html deleted file mode 100644 index ce14e4441..000000000 --- a/frontend/src/app/components/dashboard/dashboard.component.html +++ /dev/null @@ -1,141 +0,0 @@ - - - -
- {{serverError.abstract}} -

{{serverError.details}}

-
- -

{{serverError.abstract}}

-
- -
- - - - - - - -
- -
- - - @if (isConfiguring) { -
- info -
- Configuring tables - Some tables may look incomplete while setup is running. You can adjust column types, display formats and filters in - Settings and - UI widgets after setup finishes. -
-
-
-
-
- } - - - - - - - - - - - - -
-
- -
- auto_awesome -
- New: AI Configuration -

Automatically configure all your tables with AI. You can adjust changes in Settings and UI widgets.

-
- -
- - - -
- - - - -
- - -
-
-
diff --git a/frontend/src/app/components/dashboard/dashboard.component.spec.ts b/frontend/src/app/components/dashboard/dashboard.component.spec.ts deleted file mode 100644 index 1f8f135d2..000000000 --- a/frontend/src/app/components/dashboard/dashboard.component.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { BehaviorSubject, of } from 'rxjs'; -import { AccessLevel } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DashboardComponent } from './dashboard.component'; - -describe('DashboardComponent', () => { - let component: DashboardComponent; - let fixture: ComponentFixture; - let fakeTablesService; - - const fakeConnectionsSevice = { - get currentConnectionID(): string { - return ''; - }, - get currentConnectionAccessLevel(): AccessLevel { - return AccessLevel.None; - }, - getTablesFolders: () => of([]), - canEditConnection: () => false, - }; - const fakeRouter = { - navigate: vi.fn().mockReturnValue(Promise.resolve('')), - }; - - const fakeTables = [ - { - table: 'actor', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - table: 'city', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - table: 'film', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]; - - beforeEach(async () => { - // const paramMapSubject = new BehaviorSubject(convertToParamMap({ - // 'table-name': undefined - // })); - - const angulartics2Mock = { - eventTrack: { - next: () => {}, // Mocking the next method - }, - trackLocation: () => {}, // Mocking the trackLocation method - }; - - fakeTablesService = { - fetchTables: vi.fn().mockReturnValue(of(fakeTables)), - fetchTablesFolders: vi - .fn() - .mockReturnValue(of([{ category_id: 'all-tables-kitten', category_name: 'All Tables', tables: fakeTables }])), - cast: new BehaviorSubject(''), - }; - - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot(), DashboardComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: ConnectionsService, - useValue: fakeConnectionsSevice, - }, - { - provide: TablesService, - useValue: fakeTablesService, - }, - { - provide: TableRowService, - useValue: { cast: new BehaviorSubject('') }, - }, - { - provide: ActivatedRoute, - useValue: { - paramMap: of( - convertToParamMap({ - 'table-name': undefined, - }), - ), - }, - }, - { provide: Router, useValue: fakeRouter }, - { provide: Angulartics2, useValue: angulartics2Mock }, - ], - }); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DashboardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should get access level of current connection', () => { - vi.spyOn(fakeConnectionsSevice, 'currentConnectionAccessLevel', 'get').mockReturnValue(AccessLevel.Readonly); - expect(component.currentConnectionAccessLevel).toEqual('readonly'); - }); - - it('should call getData and populate tables', () => { - fakeTablesService.fetchTablesFolders.mockReturnValue( - of([{ category_id: 'all-tables-kitten', category_name: 'All Tables', tables: fakeTables }]), - ); - component.getData(); - expect(component.allTables.length).toEqual(fakeTables.length); - expect(component.allTables.map((t) => t.table)).toEqual(fakeTables.map((t) => t.table)); - }); -}); diff --git a/frontend/src/app/components/dashboard/dashboard.component.ts b/frontend/src/app/components/dashboard/dashboard.component.ts deleted file mode 100644 index ce5b8ef8b..000000000 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { SelectionModel } from '@angular/cdk/collections'; -import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, ParamMap, Router, RouterModule } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { omitBy } from 'lodash-es'; -import posthog from 'posthog-js'; -import { map } from 'rxjs/operators'; -import { getComparatorsFromUrl } from 'src/app/lib/parse-filter-params'; -import { ServerError } from 'src/app/models/alert'; -import { CustomEvent, TableProperties } from 'src/app/models/table'; -import { ConnectionSettingsUI, UiSettings } from 'src/app/models/ui-settings'; -import { User } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { ConfigurationStateService } from 'src/app/services/configuration-state.service'; -import { environment } from 'src/environments/environment'; -import { normalizeTableName } from '../../lib/normalize'; -import { PlaceholderTableViewComponent } from '../skeletons/placeholder-table-view/placeholder-table-view.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { BannerComponent } from '../ui-components/banner/banner.component'; -import { ContentLoaderComponent } from '../ui-components/content-loader/content-loader.component'; -import { DbActionLinkDialogComponent } from './db-table-view/db-action-link-dialog/db-action-link-dialog.component'; -import { BbBulkActionConfirmationDialogComponent } from './db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component'; -import { DbTableAiPanelComponent } from './db-table-view/db-table-ai-panel/db-table-ai-panel.component'; -import { DbTableFiltersDialogComponent } from './db-table-view/db-table-filters-dialog/db-table-filters-dialog.component'; -import { DbTableRowViewComponent } from './db-table-view/db-table-row-view/db-table-row-view.component'; -import { DbTableViewComponent } from './db-table-view/db-table-view.component'; -import { TablesDataSource } from './db-tables-data-source'; -import { EditDatabaseSchemaComponent } from '../edit-database-schema/edit-database-schema.component'; -import { DbTablesListComponent } from './db-tables-list/db-tables-list.component'; - -interface DataToActivateActions { - action: CustomEvent; - primaryKeys: object[]; - identityFieldValues: string[]; -} - -@Component({ - selector: 'app-dashboard', - templateUrl: './dashboard.component.html', - styleUrls: ['./dashboard.component.css'], - imports: [ - CommonModule, - MatButtonModule, - MatIconModule, - MatDialogModule, - MatSidenavModule, - EditDatabaseSchemaComponent, - DbTablesListComponent, - DbTableViewComponent, - DbTableAiPanelComponent, - DbTableRowViewComponent, - AlertComponent, - ContentLoaderComponent, - PlaceholderTableViewComponent, - BannerComponent, - RouterModule, - Angulartics2Module, - ], -}) -export class DashboardComponent implements OnInit, OnDestroy { - protected posthog = posthog; - public isSaas = (environment as any).saas; - public user: User = null; - get allTables(): TableProperties[] { - return this.tableFolders?.find((cat: any) => cat.category_id === 'all-tables-kitten')?.tables || []; - } - public selectedTableName: string; - public selectedTableDisplayName: string; - public currentPage: number = 1; - public shownTableTitles: boolean = true; - public connectionID: string; - public filters: object = {}; - public comparators: object; - public pageIndex: number; - public pageSize: number; - public sortColumn: string; - public sortOrder: 'ASC' | 'DESC'; - public loading: boolean = true; - public isServerError: boolean = false; - public serverError: ServerError; - - public noTablesError: boolean = false; - - public dataSource: TablesDataSource = null; - - public selection = new SelectionModel(true, []); - - public selectedRow = null; - public isAIpanelOpened: boolean = false; - - public uiSettings: ConnectionSettingsUI; - public tableFolders: any[] = []; - public isConfiguring: boolean = false; - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private _tableRow: TableRowService, - private _uiSettings: UiSettingsService, - private _tableState: TableStateService, - private _configState: ConfigurationStateService, - public router: Router, - private route: ActivatedRoute, - public dialog: MatDialog, - private title: Title, - private angulartics2: Angulartics2, - ) {} - - protected canEditConnection = () => this._connections.canEditConnection(); - - get currentConnectionAccessLevel() { - return this._connections.currentConnectionAccessLevel; - } - - get currentConnectionIsTest() { - return this._connections.currentConnection.isTestConnection; - } - - get connectionTitle() { - return this._connections.currentConnection?.title || 'Database'; - } - - get defaultTableToOpen() { - return this._connections.defaultTableToOpen; - } - - ngOnInit() { - this.connectionID = this._connections.currentConnectionID; - this.dataSource = new TablesDataSource(this._tables, this._connections, this._tableRow); - - // Check if auto-configure is running for this connection - this.isConfiguring = this._configState.isConfiguring(this.connectionID); - - this._tableState.cast.subscribe((row) => { - this.selectedRow = row; - }); - - this._tableState.aiPanelCast.subscribe((isAIpanelOpened) => { - this.isAIpanelOpened = isAIpanelOpened; - }); - - this._uiSettings.getUiSettings().subscribe((settings: UiSettings) => { - this.uiSettings = settings?.connections[this.connectionID]; - this.shownTableTitles = settings?.connections[this.connectionID]?.shownTableTitles ?? true; - - this.getData(); - console.log('getData from ngOnInit'); - }); - - // this.loadTableFolders(); - } - - ngOnDestroy() { - this._tableState.clearSelection(); - } - - getData() { - console.log('getData'); - - this._tables.fetchTablesFolders(this.connectionID).subscribe( - (res) => { - const tables = res.find((item) => item.category_id === 'all-tables-kitten')?.tables || []; - - this.tableFolders = res; - - if (tables && tables.length === 0) { - this.noTablesError = true; - this.loading = false; - this.title.setTitle(`No tables | Rocketadmin`); - } else if (tables) { - this.formatTableNames(); - this.route.paramMap - .pipe( - map((params: ParamMap) => { - let tableName = params.get('table-name'); - if (tableName) { - this.selectedTableName = tableName; - this.setTable(tableName); - console.log('setTable from getData paramMap'); - this.title.setTitle( - `${this.selectedTableDisplayName} table | Rocketadmin`, - ); - this.selection.clear(); - } else { - if (this.defaultTableToOpen) { - tableName = this.defaultTableToOpen; - } else { - tableName = this.allTables[0]?.table; - } - this.router.navigate([`/dashboard/${this.connectionID}/${tableName}`], { replaceUrl: true, queryParamsHandling: 'preserve' }); - this.selectedTableName = tableName; - } - }), - ) - .subscribe(); - this._tableRow.cast.subscribe((arg) => { - if (arg === 'delete row' && this.selectedTableName) { - this.setTable(this.selectedTableName); - console.log('setTable from getData _tableRow cast'); - this.selection.clear(); - } - }); - this._tables.cast.subscribe((arg) => { - if ((arg === 'delete rows' || arg === 'import') && this.selectedTableName) { - this.setTable(this.selectedTableName); - console.log('setTable from getData _tables cast'); - this.selection.clear(); - } - if (arg === 'activate actions') { - this.selection.clear(); - } - }); - } - }, - (err) => { - this.isServerError = true; - this.serverError = { abstract: err.error?.message || err.message, details: err.error?.originalMessage }; - this.loading = false; - this.title.setTitle(`Error | Rocketadmin`); - }, - ); - } - - formatTableNames() { - // Format table names inside tableFolders so all components receive formatted tables - this.tableFolders = this.tableFolders.map((category) => ({ - ...category, - tables: category.tables.map((tableItem: TableProperties) => this.formatTable(tableItem)), - })); - } - - private formatTable(tableItem: TableProperties): TableProperties { - let normalizedTableName; - if (tableItem.display_name) { - normalizedTableName = tableItem.display_name; - } else { - normalizedTableName = normalizeTableName(tableItem.table); - } - const words = normalizedTableName.split(' '); - const initials = words.reduce((result, word) => { - if (word.length > 0) { - return result + word[0].toUpperCase(); - } - return result; - }, ''); - - return { ...tableItem, normalizedTableName, initials: initials.slice(0, 2) }; - } - - setTable(tableName: string) { - this.selectedTableName = tableName; - const queryParams = this.route.snapshot.queryParams; - this.filters = JsonURL.parse(queryParams.filters); - this.comparators = getComparatorsFromUrl(this.filters); - this.pageIndex = parseInt(queryParams.page_index, 10) || 0; - this.pageSize = parseInt(queryParams.page_size, 10) || 30; - this.sortColumn = queryParams.sort_active; - this.sortOrder = queryParams.sort_direction; - - const search = queryParams.search; - const uncached = queryParams.uncached === 'true'; - this.getRows(search, uncached); - console.log('getRows from setTable'); - - const selectedTableProperties = this.allTables.find((table: any) => table.table === this.selectedTableName); - if (selectedTableProperties) { - this.selectedTableDisplayName = - selectedTableProperties.display_name || normalizeTableName(selectedTableProperties.table); - } else { - return; - } - this.loading = false; - } - - openTableFilters(structure) { - let filterDialodRef = this.dialog.open(DbTableFiltersDialogComponent, { - width: '56em', - panelClass: 'mobile-bottom-sheet-dialog', - autoFocus: false, - data: { - connectionID: this.connectionID, - tableName: this.selectedTableName, - displayTableName: this.selectedTableDisplayName, - structure, - }, - }); - - filterDialodRef.afterClosed().subscribe((action) => { - if (action === 'filter') { - const filtersFromDialog = { ...filterDialodRef.componentInstance.tableRowFieldsShown }; - - console.log('Filters from dialog:', filtersFromDialog); - - const nonEmptyFilters = omitBy(filtersFromDialog, (value) => value === undefined); - this.comparators = filterDialodRef.componentInstance.tableRowFieldsComparator; - - if (Object.keys(nonEmptyFilters).length) { - this.filters = {}; - for (const key in nonEmptyFilters) { - if (this.comparators[key] !== undefined) { - this.filters[key] = { - [this.comparators[key]]: nonEmptyFilters[key], - }; - } - } - - console.log('Filters to apply:', this.filters); - - const filters = JsonURL.stringify(this.filters); - - console.log('Filters to navigate:', filters); - - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { - queryParams: { - filters, - page_index: 0, - page_size: this.pageSize, - }, - }); - this.getRows(); - console.log('getRows from afterClosed'); - - this.angulartics2.eventTrack.next({ - action: 'Dashboard: filter is applied', - }); - posthog.capture('Dashboard: filter is applied'); - } - } else if (action === 'reset') { - this.filters = {}; - this.getRows(); - console.log('getRows from reset filters afterClosed'); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`]); - } - }); - } - - removeFilter(columnName: string) { - this.filters[columnName] = undefined; - this.filters = omitBy(this.filters, (value) => value === undefined); - - const filters = JsonURL.stringify(this.filters); - - this.selection.clear(); - - this.getRows(); - console.log('getRows from removeFilter'); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { - queryParams: { - filters, - page_index: 0, - page_size: this.pageSize, - }, - }); - } - - clearAllFilters() { - this.filters = {}; - this.comparators = {}; - this.getRows(); - console.log('getRows from clearAllFilters'); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { - queryParams: { - page_index: 0, - page_size: this.pageSize, - }, - }); - } - - search(value: string) { - this.getRows(value); - console.log('getRows from search'); - this.filters = {}; - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { - queryParams: { - page_index: 0, - page_size: this.pageSize, - search: value, - }, - }); - } - - getRows(search?: string, uncached?: boolean) { - console.log('getRows, filters:', this.filters); - this._uiSettings.getUiSettings().subscribe((settings: UiSettings) => { - this.uiSettings = settings?.connections[this.connectionID]; - this.shownTableTitles = settings?.connections[this.connectionID]?.shownTableTitles ?? true; - - this.dataSource.fetchRows({ - connectionID: this.connectionID, - tableName: this.selectedTableName, - requstedPage: this.pageIndex, - pageSize: this.pageSize, - sortColumn: this.sortColumn, - sortOrder: this.sortOrder, - filters: this.filters, - search, - uncached, - }); - }); - } - - applyFilter(filters: any) { - console.log('applyFilter with filters:', filters); - this.filters = filters?.filters; - this.getRows(); - console.log('getRows from applyFilter'); - } - - openIntercome() { - // @ts-expect-error - Intercom('show'); - } - - activateActions({ action, primaryKeys, identityFieldValues }: DataToActivateActions) { - if (action.require_confirmation) { - this.dialog.open(BbBulkActionConfirmationDialogComponent, { - width: '25em', - data: { - id: action.id, - title: action.title, - primaryKeys, - identityFieldValues, - tableDisplayName: this.selectedTableDisplayName, - }, - }); - } else { - this._tables - .activateActions(this.connectionID, this.selectedTableName, action.id, action.title, primaryKeys) - .subscribe((res) => { - if (res?.location) - this.dialog.open(DbActionLinkDialogComponent, { - width: '25em', - data: { href: res.location, actionName: action.title, primaryKeys: primaryKeys[0] }, - }); - }); - } - } - - toggleSideBar() { - this.shownTableTitles = !this.shownTableTitles; - this._uiSettings.updateConnectionSetting(this.connectionID, 'shownTableTitles', this.shownTableTitles); - } - - onSchemaApplied() { - setTimeout(() => { - this.noTablesError = false; - this.loading = true; - this.getData(); - }, 1500); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.css deleted file mode 100644 index a89be1948..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.location-link { - color: var(--color-accentedPalette-500); -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.html deleted file mode 100644 index d8b744c63..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.html +++ /dev/null @@ -1,15 +0,0 @@ -

Action

- - - Take action {{data.actionName}} for - {{data.identityFieldValue}} - - - {{primaryKey.key}} = {{primaryKey.value}}, - - . - - - - - diff --git a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.spec.ts deleted file mode 100644 index 475a3674e..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; - -import { DbActionLinkDialogComponent } from './db-action-link-dialog.component'; - -describe('DbActionLinkDialogComponent', () => { - let component: DbActionLinkDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatDialogModule, DbActionLinkDialogComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DbActionLinkDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.ts deleted file mode 100644 index 0706ac416..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; - -@Component({ - selector: 'app-db-action-link-dialog', - templateUrl: './db-action-link-dialog.component.html', - styleUrls: ['./db-action-link-dialog.component.css'], - imports: [CommonModule, MatButtonModule, MatDialogModule], -}) -export class DbActionLinkDialogComponent implements OnInit { - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - public dialogRef: MatDialogRef, - ) {} - - ngOnInit(): void {} - - closeDialog() { - this.dialogRef.close(); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.css deleted file mode 100644 index 698e8d19b..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.listOfPrimaryKeys { - list-style: none; -} - -.actions-title { - text-transform: lowercase; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.html deleted file mode 100644 index b656eda0e..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.html +++ /dev/null @@ -1,37 +0,0 @@ -

Confirm {{data.title}} for {{ data.tableDisplayName }} table

- -
-

You are going to {{data.title}} for:

-
    -
  • - - {{identityValue}}, - - . - - -
  • -
-
- -

You are going to {{data.title}} with following primary key(s)

-
    -
  • - - {{attribute.key}} = {{attribute.value}}, - - ; - - . - -
  • -
-
-
-

Please confirm.

- -
- - - - \ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.spec.ts deleted file mode 100644 index 7e026e6f4..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { TablesService } from 'src/app/services/tables.service'; -import { BbBulkActionConfirmationDialogComponent } from './db-bulk-action-confirmation-dialog.component'; - -describe('BbBulkActionConfirmationDialogComponent', () => { - let component: BbBulkActionConfirmationDialogComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - Angulartics2Module.forRoot(), - BbBulkActionConfirmationDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: MAT_DIALOG_DATA, - useValue: { - primaryKeys: [ - { - column_name: 'id', - }, - ], - selectedRows: [ - { - id: '1234', - name: 'Anna', - }, - { - id: '5678', - name: 'John', - }, - ], - }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BbBulkActionConfirmationDialogComponent); - component = fixture.componentInstance; - tablesService = TestBed.inject(TablesService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should call delete rows if no action id', () => { - component.connectionID = '12345678'; - component.selectedTableName = 'users'; - component.data.title = 'delete rows'; - component.data.primaryKeys = [{ id: 1 }, { id: 2 }, { id: 3 }]; - const fakeDeleteRows = vi.spyOn(tablesService, 'bulkDelete').mockReturnValue(of()); - - component.handleConfirmedActions(); - - expect(fakeDeleteRows).toHaveBeenCalledWith('12345678', 'users', [{ id: 1 }, { id: 2 }, { id: 3 }]); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.ts deleted file mode 100644 index 8b40049d4..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DbActionLinkDialogComponent } from '../db-action-link-dialog/db-action-link-dialog.component'; - -@Component({ - selector: 'app-db-rows-delete-dialog', - templateUrl: './db-bulk-action-confirmation-dialog.component.html', - styleUrls: ['./db-bulk-action-confirmation-dialog.component.css'], - imports: [CommonModule, FormsModule, MatButtonModule, MatDialogModule], -}) -export class BbBulkActionConfirmationDialogComponent implements OnInit { - public selectedTableName; - public submitting: boolean = false; - public connectionID: string; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _connections: ConnectionsService, - private _tables: TablesService, - private _tableRow: TableRowService, - public dialog: MatDialog, - public dialogRef: MatDialogRef, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this.selectedTableName = this._tables.currentTableName; - console.log(this.data); - this._tables.cast.subscribe(); - } - - onActionsComplete() { - this.dialogRef.close(); - this.submitting = false; - } - - handleConfirmedActions() { - this.submitting = true; - - if (this.data.id) { - this._tables - .activateActions( - this.connectionID, - this.selectedTableName, - this.data.id, - this.data.title, - this.data.primaryKeys, - true, - ) - .subscribe( - (res) => { - this.onActionsComplete(); - if (res?.location) - this.dialog.open(DbActionLinkDialogComponent, { - width: '25em', - data: { href: res.location, actionName: this.data.title, primaryKeys: this.data.primaryKeys[0] }, - }); - }, - () => { - this.onActionsComplete(); - }, - () => { - this.onActionsComplete(); - }, - ); - } else { - if (this.data.primaryKeys.length === 1) { - this._tableRow.deleteTableRow(this.connectionID, this.selectedTableName, this.data.primaryKeys[0]).subscribe( - () => { - this.onActionsComplete(); - }, - () => { - this.onActionsComplete(); - }, - () => { - this.onActionsComplete(); - }, - ); - } else if (this.data.primaryKeys.length > 1) { - this._tables.bulkDelete(this.connectionID, this.selectedTableName, this.data.primaryKeys).subscribe( - () => { - this.onActionsComplete(); - }, - () => { - this.onActionsComplete(); - }, - () => { - this.onActionsComplete(); - }, - ); - } - } - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.html deleted file mode 100644 index f79b62946..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.html +++ /dev/null @@ -1,15 +0,0 @@ -

Confirm action delete

- -

- You are going to delete {{data.action?.title}} action. -

-
-

Please confirm.

-
- - - - diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.spec.ts deleted file mode 100644 index 237a6c6b8..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { ActionDeleteDialogComponent } from './action-delete-dialog.component'; - -describe('ActionDeleteDialogComponent', () => { - let component: ActionDeleteDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - MatDialogModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - ActionDeleteDialogComponent, - ], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: {} }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ActionDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.ts deleted file mode 100644 index fb0677533..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/action-delete-dialog/action-delete-dialog.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; - -import { TablesService } from 'src/app/services/tables.service'; - -@Component({ - selector: 'app-action-delete-dialog', - templateUrl: './action-delete-dialog.component.html', - styleUrls: ['./action-delete-dialog.component.css'], - imports: [CommonModule, MatButtonModule, MatDialogModule], -}) -export class ActionDeleteDialogComponent implements OnInit { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _tables: TablesService, - public dialogRef: MatDialogRef, - ) {} - - ngOnInit(): void { - this._tables.cast.subscribe(); - } - - deleteAction() { - this.submitting = true; - this._tables.deleteRule(this.data.connectionID, this.data.selectedTableName, this.data.rule.id).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css deleted file mode 100644 index 743f58771..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css +++ /dev/null @@ -1,1148 +0,0 @@ -:host { - display: block; - height: calc(100vh - 44px); - margin-bottom: -60px; - overflow: hidden; - position: relative; - z-index: 1; -} - -:host app-alert:not(:empty) { - --alert-margin: 0; - - top: 0; - margin-bottom: 16px; -} - -.actions-layout { - display: flex; - height: 100%; -} - -.actions-sidebar { - width: clamp(200px, 18%, 320px); - padding-bottom: 16px; - border-right: 1px solid var(--mat-sidenav-container-divider-color); - overflow-y: auto; - flex-shrink: 0; -} - -.drawer-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 16px 0; -} - -.drawer-header .mat-h1 { - margin: 0; -} - -.add-automation-button { - width: calc(100% - 24px); - margin: 8px 12px; -} - -.no-actions { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - color: rgba(0, 0, 0, 0.64); - padding: 1rem 0; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .no-actions { - color: rgba(255, 255, 255, 0.64); - } -} - -/* .new-action-input { - background-color: transparent; - margin-top: 12px; - margin-left: 16px; - margin-bottom: -24px; - width: calc(100% - 32px); -} - -.new-action-input ::ng-deep.mat-mdc-form-field-wrapper { - width: calc(100% - 16px); -} */ - -.new-action-input { - background-color: transparent; - margin-left: 16px; - min-width: 220px; - width: calc(100% - 32px); -} - -.new-action-input ::ng-deep * { - background-color: transparent !important; -} - -.new-action-input ::ng-deep .mdc-text-field { - padding: 0 !important; -} - -.new-action-input ::ng-deep .mat-mdc-form-field-infix { - min-height: 0; -} - -.new-action-input - ::ng-deep - .mdc-text-field--no-label:not(.mdc-text-field--outlined):not(.mdc-text-field--textarea) - .mat-mdc-form-field-infix { - padding-top: 8px; - padding-bottom: 8px; -} - -.new-action-input ::ng-deep .mat-mdc-icon-button.mat-mdc-button-base { - width: 36px; - height: 36px; - padding: 6px; -} - -.action-error { - position: absolute; - top: 44px; - left: 0; - color: var(--color-error); - font-size: 0.75em; - width: 100%; -} - -.actions-main { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - background-color: var(--mat-sidenav-content-background-color); -} - -.actions-main > app-alert, -.actions-main > app-breadcrumbs, -.actions-main > app-content-loader, -.actions-main > .creation-flow, -.actions-main > .empty-state { - padding-left: clamp(40px, 5vw, 100px); - padding-right: clamp(40px, 5vw, 100px); -} - -.actions-main > app-alert, -.actions-main > app-breadcrumbs, -.actions-main > app-content-loader { - padding-top: 20px; -} - -@media (width <= 600px) { - .actions-main > app-alert, - .actions-main > app-breadcrumbs, - .actions-main > app-content-loader, - .actions-main > .creation-flow, - .actions-main > .empty-state { - padding-left: 16px; - padding-right: 16px; - } -} - -.rule { - flex-grow: 1; - display: flex; - align-items: stretch; - gap: 24px; - margin-top: 20px; -} - -.rule-settings { - display: flex; - flex-direction: column; - gap: 4px; - /* min-width: 420px; */ - width: 100%; -} - -.rule-name { - width: 50%; -} - -::ng-deep .mat-menu-content { - padding: 16px !important; -} - -.text_highlighted { - background: var(--color-accentedPalette-100); - color: var(--color-accentedPalette-100-contrast); - margin-bottom: 0 !important; - padding: 4px; -} - -@media (prefers-color-scheme: dark) { - .text_highlighted { - background: var(--color-accentedPalette-800); - color: var(--color-accentedPalette-800-contrast); - } -} - -.event { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - margin-top: 24px; -} - -.custom-event { - display: flex; - flex-direction: column; - /* padding-left: 128px; */ - padding-top: 20px; - padding-bottom: 32px; -} - -.custom-event__row { - display: flex; - align-items: center; - gap: 24px; -} - -.list-action-list-item ::ng-deep .mdc-list-item__primary-text { - display: flex; - justify-content: space-between; - align-items: center; -} - -.list-action-list-item_active { - --mat-list-list-item-label-text-color: var(--color-accentedPalette-500); - --mat-list-list-item-hover-label-text-color: var(--color-accentedPalette-500); - --mat-list-list-item-focus-label-text-color: var(--color-accentedPalette-500); -} - -.radio-button_first { - margin-left: 16px; -} - -.radio-button_second { - margin-left: 8px; -} - -.action__actions { - margin-top: auto; - display: flex; - justify-content: space-between; -} - -.code-snippet-box { - position: relative; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - margin-top: 8px; - margin-bottom: 20px; - max-width: 1100px; - padding: 8px 12px; -} - -.copy-button { - position: absolute; - right: 32px; - top: 32px; - background-color: #fff; - z-index: 5; -} - -@media (prefers-color-scheme: dark) { - .code-snippet-box { - border-color: rgba(255, 255, 255, 0.12); - background-color: var(--surface-dark-color); - } - - .copy-button { - background-color: var(--surface-dark-color); - } -} - -@media screen and (max-width: 1400px) { - .copy-button { - right: 20px; - top: 100px; - } -} - -.confirmation-checkbox, -.icon-picker { - margin-top: -16px; -} - -.event { - display: flex; - align-items: center; - gap: 8px; -} - -.event__or { - margin-top: -22px !important; -} - -.event__removeButton { - margin-top: -22px; -} - -.rule-action { - display: flex; - align-items: center; - gap: 16px; -} - -.rule-action__label { - display: inline-block; - margin-top: -16px !important; -} - -.rule-action__param { - width: 50%; -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - margin: 40px auto 0; - max-width: 900px; -} - -.empty-state__header { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - margin-bottom: 8px; -} - -.empty-state__icon-box { - width: 40px; - height: 40px; - border-radius: 10px; - background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; -} - -.empty-state__icon { - color: var(--color-accentedPalette-500); - font-size: 22px; - width: 22px; - height: 22px; -} - -.empty-state__title { - font-size: 20px; - font-weight: 600; - margin: 0 0 2px; - line-height: 1.3; -} - -.empty-state__subtitle { - font-size: 14px; - font-weight: 300; - color: rgba(0, 0, 0, 0.64); - margin: 0 !important; - line-height: 1.5; -} - -@media (prefers-color-scheme: dark) { - .empty-state__subtitle { - color: rgba(255, 255, 255, 0.64); - } -} - -.empty-state__cta { - display: flex; - align-items: center; - gap: 16px; - margin-top: 16px; -} - -.empty-state__docs-link { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 13px; - color: var(--color-accentedPalette-500); - text-decoration: none; -} - -.empty-state__docs-link:hover { - text-decoration: underline; -} - -.empty-state__docs-icon { - font-size: 15px; - width: 15px; - height: 15px; -} - -/* ── Example flow ── */ - -.example-flow { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 520px; -} - -.example-flow__card { - display: flex; - flex-direction: column; - gap: 0; - padding: 24px; - border: 1px solid var(--color-accentedPalette-100); - border-radius: 20px; - width: 100%; - box-sizing: border-box; - opacity: 0.85; - pointer-events: none; - background: var(--color-accentedPalette-50); -} - -@media (prefers-color-scheme: dark) { - .example-flow__card { - border-color: var(--color-accentedPalette-800); - background: var(--color-accentedPalette-900); - } -} - -/* ── Badges ── */ - -.example-flow__section-label { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; -} - -.example-flow__section-text { - font-size: 13px; - font-weight: 500; -} - -.example-flow__badge { - display: inline-flex; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; - padding: 3px 10px; - border-radius: 6px; -} - -.example-flow__badge--when { - background: #ede9fe; - color: var(--color-alternativePalette-500); -} - -.example-flow__badge--action { - background: #dbeafe; - color: #3b82f6; -} - -@media (prefers-color-scheme: dark) { - .example-flow__badge--when { - background: #3730a3; - color: #a5b4fc; - } - - .example-flow__badge--action { - background: #1e3a5f; - color: #60a5fa; - } -} - -/* ── Trigger grid ── */ - -.example-flow__triggers { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.example-trigger { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 12px; - background: #ffffff; - border: 1px solid var(--color-accentedPalette-100); -} - -@media (prefers-color-scheme: dark) { - .example-trigger { - background: #13151a; - border-color: var(--color-accentedPalette-800); - } -} - -.example-trigger__icon-box { - width: 34px; - height: 34px; - border-radius: 9px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.example-trigger__icon-box mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.example-trigger__icon-box--add { - background: color-mix(in srgb, var(--color-successPalette-500), transparent 80%); - color: var(--color-successPalette-500); -} - -.example-trigger__icon-box--update { - background: color-mix(in srgb, var(--color-infoPalette-500), transparent 80%); - color: var(--color-infoPalette-500); -} - -.example-trigger__icon-box--delete { - background: color-mix(in srgb, var(--color-warnPalette-500), transparent 80%); - color: var(--color-warnPalette-500); -} - -.example-trigger__icon-box--custom { - background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 80%); - color: var(--color-alternativePalette-500); -} - -@media (prefers-color-scheme: dark) { - .example-trigger__icon-box--add { - background: color-mix(in srgb, var(--color-successPalette-500), transparent 88%); - color: var(--color-successPalette-500); - } - .example-trigger__icon-box--update { - background: color-mix(in srgb, var(--color-infoPalette-500), transparent 88%); - color: var(--color-infoPalette-500); - } - .example-trigger__icon-box--delete { - background: color-mix(in srgb, var(--color-warnPalette-500), transparent 88%); - color: var(--color-warnPalette-500); - } - .example-trigger__icon-box--custom { - background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 88%); - color: var(--color-alternativePalette-500); - } -} - -.example-trigger__text { - display: flex; - flex-direction: column; - gap: 1px; -} - -.example-trigger__title { - font-size: 13px; - font-weight: 600; -} - -.example-trigger__desc { - font-size: 11px; - color: rgba(0, 0, 0, 0.64); -} - -@media (prefers-color-scheme: dark) { - .example-trigger__desc { - color: rgba(255, 255, 255, 0.64); - } -} - -/* ── Connector ── */ - -.example-flow__connector { - display: flex; - flex-direction: column; - align-items: center; - height: 48px; - margin: 4px 0; -} - -.example-flow__connector-line { - flex: 1; - border-left: 1px solid var(--color-accentedPalette-100); -} - -@media (prefers-color-scheme: dark) { - .example-flow__connector-line { - border-color: var(--color-accentedPalette-800); - } -} - -.example-flow__connector-pill { - font-size: 10px; - font-weight: 700; - letter-spacing: 0.05em; - padding: 2px 8px; - border-radius: 4px; -} - -@media (prefers-color-scheme: dark) { - .example-flow__connector-pill { - background: #1e2130; - } -} - -/* ── Actions ── */ - -.example-flow__actions { - display: flex; - gap: 10px; -} - -.example-action { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - padding: 14px 8px; - border-radius: 12px; - background: #ffffff; - border: 1px solid var(--color-accentedPalette-100); -} - -@media (prefers-color-scheme: dark) { - .example-action { - background: #13151a; - border-color: var(--color-accentedPalette-800); - } -} - -.example-action__icon-box { - width: 40px; - height: 40px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); - color: var(--color-accentedPalette-500); -} - -@media (prefers-color-scheme: dark) { - .example-action__icon-box { - background: #1e2130; - color: var(--color-accentedPalette-500); - } -} - -.example-action__icon-box mat-icon { - font-size: 20px; - width: 20px; - height: 20px; -} - -.example-action__label { - font-size: 12px; - font-weight: 600; -} - -/* ── Creation flow ── */ - -.creation-flow { - flex: 1; - margin-top: 20px; - max-width: 1400px; - width: 100%; - box-sizing: border-box; -} - -.creation-flow__form { - display: flex; - flex-direction: column; - gap: 24px; - min-height: 100%; -} - -.creation-flow__form > .actions { - margin-top: auto; -} - -.creation-flow__form .creation-card { - max-width: 900px; -} - -/* ── Card connector (line + arrow) ── */ - -.creation-card--trigger::after { - content: ""; - position: absolute; - bottom: -3px; - left: 50%; - transform: translateX(-50%); - width: 6px; - height: 6px; - border-radius: 50%; - background-color: var(--color-accentedPalette-300); - z-index: 1; -} - -.card-connector { - display: flex; - flex-direction: column; - align-items: center; - height: 36px; - margin: -18px 0; - max-width: 900px; -} - -.card-connector::before { - content: ""; - flex: 1; - border-left: 1px solid var(--color-accentedPalette-300); -} - -.card-connector::after { - content: ""; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 5px solid var(--color-accentedPalette-300); -} - -.creation-flow__name-row { - display: flex; - align-items: center; - gap: 8px; - max-width: 900px; -} - -.creation-flow__name-row > button { - margin-left: auto; - margin-bottom: 22px; -} - -.creation-flow__name { - width: 50%; -} - -.actions { - position: sticky; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-left: calc(-1 * clamp(40px, 5vw, 100px)); - margin-right: calc(-1 * clamp(40px, 5vw, 100px)); - padding: 0 clamp(40px, 5vw, 100px); - background-color: var(--background-color); - box-shadow: var(--shadow); - height: 64px; - flex-shrink: 0; - z-index: 10; -} - -@media (prefers-color-scheme: dark) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.5), 0 2px 2px 0 rgba(0, 0, 0, 0.64), 0 1px 5px 0 rgba(0, 0, 0, 0.85); - --background-color: var(--surface-dark-color); - } -} - -@media (prefers-color-scheme: light) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); - --background-color: #fff; - } -} - -@media (width <= 600px) { - .actions { - margin-left: -16px; - margin-right: -16px; - padding: 0 16px; - } -} - -/* ── Step badges ── */ - -.step-label__badge { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.75em; - font-weight: 700; - letter-spacing: 0.05em; - padding: 2px 7px; - border-radius: 4px; - margin-right: 8px; -} - -.step-label__badge--when { - background-color: #3d2248; - color: #d1a3db; -} - -.step-label__badge--then { - background-color: #1a2d4a; - color: #82c4f8; -} - -@media (prefers-color-scheme: light) { - .step-label__badge--when { - background-color: #f3e5f5; - color: #7b1fa2; - } - - .step-label__badge--then { - background-color: #e3f2fd; - color: #1565c0; - } -} - -/* ── Cards ── */ - -.creation-card { - position: relative; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - padding: 20px 24px; -} - -@media (prefers-color-scheme: dark) { - .creation-card { - border-color: rgba(255, 255, 255, 0.12); - background-color: #1e1e1e; - } -} - -.creation-card--disabled { - opacity: 0.35; - pointer-events: none; -} - -.creation-card--highlight { - animation: card-pulse 1.5s ease; -} - -@keyframes card-pulse { - 0%, - 100% { - border-color: rgba(0, 0, 0, 0.12); - } - 25%, - 75% { - border-color: var(--color-accentedPalette-500); - } -} - -@media (prefers-color-scheme: dark) { - @keyframes card-pulse { - 0%, - 100% { - border-color: rgba(255, 255, 255, 0.12); - } - 25%, - 75% { - border-color: var(--color-accentedPalette-500); - } - } -} - -.creation-card__label { - font-weight: 500; - display: block; - margin-bottom: 16px; -} - -.creation-card__hint { - font-style: italic; - color: rgba(0, 0, 0, 0.45); -} - -@media (prefers-color-scheme: dark) { - .creation-card__hint { - color: rgba(255, 255, 255, 0.45); - } -} - -/* ── Trigger pills ── */ - -.trigger-pills { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.trigger-pills__or { - font-size: 11px; - font-weight: 500; - color: rgba(255, 255, 255, 0.24); - text-transform: uppercase; - letter-spacing: 0.03em; -} - -@media (prefers-color-scheme: light) { - .trigger-pills__or { - color: rgba(0, 0, 0, 0.32); - } -} - -.trigger-pill { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 7px 14px; - border-radius: 4px; - border: 1px solid var(--mat-button-outlined-outline-color); - background: #0f0f18; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: - border-color 0.2s, - background-color 0.2s, - box-shadow 0.2s; -} - -.trigger-pill__checkbox { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.trigger-pill__icon { - font-size: 15px; - width: 15px; - height: 15px; -} - -/* Default colored text & icons per type (muted when not selected) */ -.trigger-pill--add-row { - color: var(--color-successDarkPalette-700); -} -.trigger-pill--update-row { - color: var(--color-infoPalette-700); -} -.trigger-pill--delete-row { - color: var(--color-warnDarkPalette-700); -} -.trigger-pill--custom { - color: var(--color-alternativeDarkPalette-700); -} - -.trigger-pill:hover { - background: rgba(255, 255, 255, 0.036); -} - -/* Active states */ -.trigger-pill--add-row.trigger-pill--active { - border-color: var(--color-successDarkPalette-500); - background: var(--color-successDarkPalette-900); - box-shadow: - 0 0 0 1px var(--color-successDarkPalette-800), - 0 0 12px var(--color-successDarkPalette-900); - color: var(--color-successDarkPalette-500); -} - -.trigger-pill--update-row.trigger-pill--active { - border-color: var(--color-infoPalette-500); - background: var(--color-infoPalette-900); - box-shadow: - 0 0 0 1px var(--color-infoPalette-800), - 0 0 12px var(--color-infoPalette-900); - color: var(--color-infoPalette-500); -} - -.trigger-pill--delete-row.trigger-pill--active { - border-color: var(--color-warnDarkPalette-500); - background: var(--color-warnDarkPalette-900); - box-shadow: - 0 0 0 1px var(--color-warnDarkPalette-800), - 0 0 12px var(--color-warnDarkPalette-900); - color: var(--color-warnDarkPalette-500); -} - -.trigger-pill--custom.trigger-pill--active { - border-color: var(--color-alternativeDarkPalette-500); - background: var(--color-alternativeDarkPalette-900); - box-shadow: - 0 0 0 1px var(--color-alternativeDarkPalette-800), - 0 0 12px var(--color-alternativeDarkPalette-900); - color: var(--color-alternativeDarkPalette-500); -} - -/* Light mode overrides */ -@media (prefers-color-scheme: light) { - /* Default colored text & icons per type (muted when not selected) */ - .trigger-pill--add-row { - color: var(--color-successPalette-300); - background-color: transparent; - } - .trigger-pill--update-row { - color: var(--color-infoPalette-300); - background-color: transparent; - } - .trigger-pill--delete-row { - color: var(--color-warnPalette-300); - background-color: transparent; - } - .trigger-pill--custom { - color: var(--color-alternativePalette-300); - background-color: transparent; - } - - .trigger-pill:hover { - background: rgba(255, 255, 255, 0.036); - } - - /* Active states */ - .trigger-pill--add-row.trigger-pill--active { - border-color: var(--color-successPalette-500); - background: var(--color-successPalette-100); - box-shadow: - 0 0 0 1px var(--color-successPalette-100), - 0 0 12px var(--color-successPalette-50); - color: var(--color-successPalette-500); - } - - .trigger-pill--update-row.trigger-pill--active { - border-color: var(--color-infoPalette-500); - background: var(--color-infoPalette-100); - box-shadow: - 0 0 0 1px var(--color-infoPalette-100), - 0 0 12px var(--color-infoPalette-50); - color: var(--color-infoPalette-500); - } - - .trigger-pill--delete-row.trigger-pill--active { - border-color: var(--color-warnPalette-500); - background: var(--color-warnPalette-100); - box-shadow: - 0 0 0 1px var(--color-warnPalette-100), - 0 0 12px var(--color-warnPalette-50); - color: var(--color-warnPalette-500); - } - - .trigger-pill--custom.trigger-pill--active { - border-color: var(--color-alternativePalette-500); - background: var(--color-alternativePalette-100); - box-shadow: - 0 0 0 1px var(--color-alternativePalette-100), - 0 0 12px var(--color-alternativePalette-50); - color: var(--color-alternativePalette-500); - } -} - -/* Custom event inline */ -.custom-event-inline { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 16px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid rgba(0, 0, 0, 0.08); -} - -.custom-event-inline app-icon-picker, -.custom-event-inline mat-checkbox { - margin-bottom: 22px; -} - -@media (prefers-color-scheme: dark) { - .custom-event-inline { - border-top-color: rgba(255, 255, 255, 0.08); - } -} - -/* ── Affects row ── */ - -.affects-row { - display: flex; - align-items: center; - gap: 16px; - margin-top: -8px; - width: 100%; -} - -.affects-row__label { - font-weight: 500; - font-size: 0.85em; - color: rgba(0, 0, 0, 0.64); -} - -@media (prefers-color-scheme: dark) { - .affects-row__label { - color: rgba(255, 255, 255, 0.64); - } -} - -/* ── Accented toggle groups (affects + action methods) ── */ - -.affects-row ::ng-deep .mat-button-toggle-checked, -.action-methods ::ng-deep .mat-button-toggle-checked { - background-color: var(--color-accentedPalette-100); - color: var(--color-accentedPalette-100-contrast); -} - -@media (prefers-color-scheme: dark) { - .affects-row ::ng-deep .mat-button-toggle-checked, - .action-methods ::ng-deep .mat-button-toggle-checked { - background-color: var(--color-accentedPalette-800); - color: var(--color-accentedPalette-800-contrast); - } -} - -/* ── Action methods ── */ - -.action-methods { - margin-bottom: 16px; -} - -.action-methods ::ng-deep .mat-button-toggle-label-content { - display: flex; - align-items: center; - gap: 4px; -} - -.action-methods mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -/* ── Action fields ── */ - -.action-fields__input { - width: 60%; -} - -.action-fields__hint { - margin-top: -12px; - margin-bottom: 8px; - font-size: 0.85em; - color: rgba(0, 0, 0, 0.5); -} - -@media (prefers-color-scheme: dark) { - .action-fields__hint { - color: rgba(255, 255, 255, 0.5); - } -} - -@media screen and (max-width: 768px) { - .creation-flow__name, - .action-fields__input { - width: 100%; - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.html deleted file mode 100644 index fc10422cf..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.html +++ /dev/null @@ -1,318 +0,0 @@ -
-
-
-

Automations

-
-
- No automations yet -
- - - - -
- -
- - - - -
-
-
- - Automation name - - - @if (isCreationMode) { - - } @else { - - } -
- - -
- WHEN Trigger - -
- - or - - or - - or - -
- - @if (selectedRuleCustomEvent) { -
- - Button label - - - - - - Request confirmation - -
- Affects - - Single row - Multiple rows - -
-
- } -
- -
- - -
- THEN Action - @if (!hasTriggersSelected) { - Add a trigger first - } @else { - - - link URL webhook - - - email Email - - - tag Slack - - - -
- @if ($any(selectedRule.table_actions[0]).method === 'URL') { - - Webhook URL - - -

Use the code snippets below to verify requests.

- -
- - - - - - - -
- } - - @if ($any(selectedRule.table_actions[0]).method === 'EMAIL') { - - Notification emails - - - {{companyMember.name}} | {{companyMember.email}} - - - - } - - @if ($any(selectedRule.table_actions[0]).method === 'SLACK') { - - Slack webhook URL - - - } -
- } -
- -
- - Back - - - -
-
-
- -
-
-
- bolt -
-

Automate your database workflows

-

Set up triggers and actions to react to data changes automatically.

-
- - - Docs open_in_new - -
-
- -
-
- - -
-
-
- add_circle_outline -
-
- Add row - New record is created -
-
-
-
- edit -
-
- Update row - Record is modified -
-
-
-
- delete_outline -
-
- Delete row - Record is removed -
-
-
-
- download -
-
- Custom - Custom button trigger -
-
-
- - -
-
- THEN -
-
- - - -
-
-
- webhook -
- Webhook -
-
-
- email -
- Email -
-
-
- tag -
- Slack -
-
-
-
-
- -
-
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.spec.ts deleted file mode 100644 index 33a3bd286..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.spec.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { CustomActionMethod } from 'src/app/models/table'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { ActionDeleteDialogComponent } from './action-delete-dialog/action-delete-dialog.component'; -import { DbTableActionsComponent } from './db-table-actions.component'; - -describe('DbTableActionsComponent', () => { - let component: DbTableActionsComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let mockMatDialog: { open: ReturnType }; - let fakeNotifications; - - beforeEach(async () => { - fakeNotifications = { - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - showErrorSnackbar: vi.fn(), - }; - mockMatDialog = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - MatSnackBarModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - DbTableActionsComponent, - ], - providers: [ - provideHttpClient(), - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - ], - }) - .overrideComponent(DbTableActionsComponent, { - remove: { imports: [CodeEditorModule] }, - add: { - imports: [MockCodeEditorComponent], - schemas: [NO_ERRORS_SCHEMA], - providers: [{ provide: MatDialog, useFactory: () => mockMatDialog }], - }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(DbTableActionsComponent); - tablesService = TestBed.inject(TablesService); - component = fixture.componentInstance; - - // Mock fetchRules before detectChanges to prevent ngOnInit errors - vi.spyOn(tablesService, 'fetchRules').mockReturnValue( - of({ - action_rules: [], - display_name: 'Test Table', - }), - ); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set selected rule', () => { - const rule = { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - component.setSelectedRule(rule); - expect(component.selectedRule).toEqual(rule); - expect(component.selectedRuleTitle).toEqual('rule 1'); - }); - - it('should switch between rules on rules list click', () => { - const rule = { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - const mockSetSelectedAction = vi.spyOn(component, 'setSelectedRule'); - - component.switchRulesView(rule); - - expect(mockSetSelectedAction).toHaveBeenCalledWith(rule); - }); - - it('should set the new rule', () => { - component.tableName = 'user'; - component.addNewRule(); - - expect(component.newRule).toEqual({ - id: '', - title: '', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [ - { - method: CustomActionMethod.URL, - emails: [], - url: '', - }, - ], - }); - }); - - it('should set an error if user try to add rule with empty name to the list', () => { - component.newRule = { - id: '', - title: '', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [ - { - method: CustomActionMethod.URL, - emails: [], - url: '', - }, - ], - }; - component.handleAddNewRule(); - - expect(component.actionNameError).toEqual('The name cannot be empty.'); - }); - - it('should set an error if user try to add rule with the same name to the list', () => { - component.rules = [ - { - id: '', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }, - ]; - component.newRule = { - id: '', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }; - - component.handleAddNewRule(); - - expect(component.actionNameError).toEqual('You already have an action with this name.'); - }); - - it('should add new rule to the list and switch to selected rule', () => { - const mockNewRule = { - id: '', - title: 'rule 2', - table_name: 'user', - events: [], - table_actions: [], - }; - - component.rules = [ - { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }, - ]; - - component.newRule = mockNewRule; - - component.handleAddNewRule(); - - expect(component.selectedRule).toEqual(mockNewRule); - expect(component.selectedRuleTitle).toEqual('rule 2'); - expect(component.rules).toEqual([ - { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }, - { - id: '', - title: 'rule 2', - table_name: 'user', - events: [], - table_actions: [], - }, - ]); - expect(component.newRule).toBeNull(); - }); - - it('should remove rule if it is not saved and if it is not in the list yet and the list is empty', () => { - component.newRule = { - id: '', - title: 'rule 2', - table_name: 'user', - events: [], - table_actions: [], - }; - - component.rules = []; - - component.undoRule(); - - expect(component.selectedRule).toBeNull(); - expect(component.newRule).toBeNull(); - }); - - it('should remove rule if it is not saved and if it is not in the list yet and switch to the first rule in the list', () => { - const mockRule = { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - component.newRule = { - id: '', - title: 'rule 2', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - component.rules = [mockRule]; - - component.undoRule(); - - expect(component.selectedRule).toEqual(mockRule); - expect(component.newRule).toBeNull(); - }); - - it('should call remove action from the list when it is not saved', () => { - component.rules = [ - { - id: '', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }, - ]; - component.selectedRule = { - id: '', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - const mockRemoveRuleFromLocalList = vi.spyOn(component, 'removeRuleFromLocalList'); - - component.handleRemoveRule(); - - expect(mockRemoveRuleFromLocalList).toHaveBeenCalledWith('rule 1'); - }); - - it('should call open delete confirm dialog when action is saved', () => { - component.selectedRule = { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }; - const mockOpenDeleteRuleDialog = vi.spyOn(component, 'openDeleteRuleDialog'); - - component.handleRemoveRule(); - - expect(mockOpenDeleteRuleDialog).toHaveBeenCalledWith(); - }); - - it('should remove rule from the list if it is not saved and if it is only one actions in the list', () => { - component.rules = [ - { - id: 'rule_12345678', - title: 'rule 1', - table_name: 'user', - events: [], - table_actions: [], - }, - ]; - - component.removeRuleFromLocalList('rule 1'); - - expect(component.selectedRule).toBeNull(); - expect(component.rules).toEqual([]); - }); - - it('should remove rule from the list if it is not saved and make active the first rule in the list', () => { - const mockRule = { - id: '', - title: 'rule 2', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }; - component.rules = [ - { - id: '', - title: 'rule 1', - table_name: 'user', - events: [ - { - event: null, - }, - ], - table_actions: [], - }, - mockRule, - ]; - - component.removeRuleFromLocalList('rule 1'); - - expect(component.selectedRule).toEqual(mockRule); - expect(component.rules).toEqual([mockRule]); - }); - - it('should save rule', () => { - component.tableName = 'users'; - const mockRule = { - id: '', - title: 'rule 2', - table_name: '', - events: [], - table_actions: [], - }; - component.connectionID = '12345678'; - component.selectedRule = mockRule; - const fakeSaveAction = vi.spyOn(tablesService, 'saveRule').mockReturnValue(of()); - - component.addRule(); - - expect(fakeSaveAction).toHaveBeenCalledWith('12345678', 'users', mockRule); - }); - - it('should open dialog for delete action confirmation', () => { - const mockRule = { - id: '', - title: 'rule 2', - table_name: 'user', - events: [], - table_actions: [], - }; - component.connectionID = '12345678'; - component.tableName = 'users'; - component.selectedRule = mockRule; - - component.openDeleteRuleDialog(); - - expect(mockMatDialog.open).toHaveBeenCalledWith(ActionDeleteDialogComponent, { - width: '25em', - data: { - connectionID: '12345678', - tableName: 'users', - rule: mockRule, - }, - }); - }); - - it('should show Copy message', () => { - component.showCopyNotification('PHP code snippet was copied to clipboard.'); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('PHP code snippet was copied to clipboard.'); - - fakeNotifications.showSuccessSnackbar.mockClear(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.ts deleted file mode 100644 index b0cf2dd44..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatListModule } from '@angular/material/list'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import posthog from 'posthog-js'; -import { codeSnippets } from 'src/app/consts/code-snippets'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { CompanyMember } from 'src/app/models/company'; -import { CustomAction, CustomActionMethod, CustomActionType, CustomEvent, EventType, Rule } from 'src/app/models/table'; -import { CompanyService } from 'src/app/services/company.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { UserService } from 'src/app/services/user.service'; -import { AlertComponent } from '../../../ui-components/alert/alert.component'; -import { BreadcrumbsComponent } from '../../../ui-components/breadcrumbs/breadcrumbs.component'; -import { ContentLoaderComponent } from '../../../ui-components/content-loader/content-loader.component'; -import { IconPickerComponent } from '../../../ui-components/icon-picker/icon-picker.component'; -import { ActionDeleteDialogComponent } from './action-delete-dialog/action-delete-dialog.component'; - -@Component({ - selector: 'app-db-table-actions', - templateUrl: './db-table-actions.component.html', - styleUrls: ['./db-table-actions.component.css'], - imports: [ - CommonModule, - ClipboardModule, - FormsModule, - MatButtonModule, - MatButtonToggleModule, - MatCheckboxModule, - MatDialogModule, - MatIconModule, - MatInputModule, - MatSelectModule, - MatTooltipModule, - MatListModule, - MatMenuModule, - MatRadioModule, - MatTabsModule, - CodeEditorModule, - AlertComponent, - BreadcrumbsComponent, - ContentLoaderComponent, - IconPickerComponent, - Angulartics2OnModule, - RouterModule, - ], -}) -export class DbTableActionsComponent implements OnInit { - protected posthog = posthog; - public connectionID: string | null = null; - public tableName: string | null = null; - public normalizedTableName: string; - public rulesData: { - table_name: string; - display_name: string; - action_rules: Rule[]; - }; - public rules: Rule[]; - public submitting: boolean; - public isSaved = false; - public selectedRule: Rule = null; - public selectedRuleCustomEvent: CustomEvent = null; - public customAction: CustomAction = null; - public selectedRuleTitle: string; - public newRule: Rule = null; - public newAction: CustomAction = null; - public actionNameError: string; - public codeSnippets: object; - public companyMembers: CompanyMember[]; - - public defaultIcons = [ - 'favorite_outline', - 'star_outline', - 'done', - 'arrow_forward', - 'key_outline', - 'lock', - 'visibility', - 'language', - 'notifications', - 'schedule', - ]; - - @ViewChild('newActionInput') newActionInput: ElementRef; - @ViewChild('ruleNameInput') ruleNameInput: ElementRef; - - public signingKey: string; - - public codeViewerOptions = { - minimap: { enabled: false }, - scrollBeyondLastLine: false, - wordWrap: 'on', - automaticLayout: true, - }; - - public availableEvents = [ - { value: EventType.AddRow, label: 'Add row' }, - { value: EventType.UpdateRow, label: 'Update row' }, - { value: EventType.DeleteRow, label: 'Delete row' }, - { value: EventType.Custom, label: 'Custom' }, - ]; - - public triggerMeta: Record = { - [EventType.AddRow]: { - icon: 'add_circle_outline', - desc: 'Fires when a new row is inserted', - color: 'var(--color-successPalette-500)', - }, - [EventType.UpdateRow]: { - icon: 'edit', - desc: 'Fires when an existing row is modified', - color: 'var(--color-infoPalette-500)', - }, - [EventType.DeleteRow]: { - icon: 'delete_outline', - desc: 'Fires when a row is removed', - color: 'var(--color-warnPalette-500)', - }, - [EventType.Custom]: { icon: 'tune', desc: 'Manual trigger with custom button', color: '#9c27b0' }, - }; - public selectedEvents: string[] = []; - public codeEditorTheme: 'vs' | 'vs-dark' = 'vs-dark'; - public isCreationMode = false; - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private _notifications: NotificationsService, - private _company: CompanyService, - private _userService: UserService, - private _uiSettings: UiSettingsService, - public dialog: MatDialog, - private title: Title, - private angulartics2: Angulartics2, - ) {} - - async ngOnInit() { - this.connectionID = this._connections.currentConnectionID; - this.tableName = this._tables.currentTableName; - this.normalizedTableName = normalizeTableName(this.tableName); - this._connections - .getCurrentConnectionSigningKey() - .subscribe((signingKey) => (this.codeSnippets = codeSnippets(signingKey))); - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - - try { - this.rulesData = await this.getRules(); - console.log(this.rulesData); - this.rules = this.rulesData.action_rules; - if (this.rules.length) this.setSelectedRule(this.rules[0]); - this.title.setTitle(`${this.rulesData.display_name || this.normalizedTableName} - Actions | Rocketadmin`); - } catch (error) { - if (error instanceof HttpErrorResponse) { - this.rulesData = null; - console.log(error.error.message); - } else { - throw error; - } - } - - this._userService.cast.subscribe((user) => { - this._company.fetchCompanyMembers(user.company.id).subscribe((members) => { - this.companyMembers = members; - }); - }); - - this._tables.cast.subscribe(async (arg) => { - if (arg === 'delete-rule') { - try { - this.rulesData = await this.getRules(); - this.rules = this.rulesData.action_rules; - if (this.rules.length) this.setSelectedRule(this.rules[0]); - } catch (error) { - if (error instanceof HttpErrorResponse) { - console.log(error.error.message); - } else { - throw error; - } - } - } - }); - } - - get hasTriggersSelected(): boolean { - return this.selectedRule?.events?.some((e) => e.event !== null) ?? false; - } - - get saveDisabledReason(): string { - if (!this.selectedRuleTitle) return 'Enter automation name'; - if (!this.hasTriggersSelected) return 'Select at least one trigger'; - return ''; - } - - get activeTriggers() { - return this.selectedRule?.events?.filter((e) => e.event !== null) ?? []; - } - - get unusedTriggerOptions() { - return this.availableEvents.filter((e) => !this.selectedEvents.includes(e.value)); - } - - get currentConnection() { - // this.codeSnippets = codeSnippets(this._connections.currentConnection.signing_key); - return this._connections.currentConnection; - } - - // get codeSnippets() { - // return codeSnippets(this._connections.currentConnection.signing_key); - // } - - getCrumbs(name: string) { - return [ - { - label: name, - link: `/dashboard/${this.connectionID}`, - }, - { - label: this.rulesData.display_name || this.normalizedTableName, - link: `/dashboard/${this.connectionID}/${this.tableName}`, - }, - { - label: 'Automations', - link: null, - }, - ]; - } - - trackByFn(index: number) { - return index; // or item.id - } - - setSelectedRule(rule: Rule) { - this.selectedRule = rule; - this.selectedRuleTitle = rule.title; - this.isCreationMode = !rule.id; - if (this.selectedRule.events[this.selectedRule.events.length - 1].event !== null) - this.selectedRule.events.push({ event: null }); - this.selectedEvents = this.selectedRule.events.map((event) => event.event); - - const customEvent = this.selectedRule.events.find((event) => event.event === EventType.Custom); - if (customEvent) { - this.selectedRuleCustomEvent = customEvent as CustomEvent; - } else { - this.selectedRuleCustomEvent = null; - } - } - - updateIcon(icon: string) { - this.selectedRuleCustomEvent.icon = icon; - } - - setCustomEventType(type: string) { - this.selectedRuleCustomEvent.type = type as CustomActionType; - this.isSaved = false; - } - - switchRulesView(rule: Rule) { - this.setSelectedRule(rule); - } - - addNewRule() { - this.newRule = { - id: '', - title: '', - table_name: this.tableName, - events: [ - { - event: null, - }, - ], - table_actions: [ - { - method: CustomActionMethod.URL, - emails: [], - url: '', - }, - ], - }; - - this.rules.push(this.newRule); - this.setSelectedRule(this.newRule); - this.isCreationMode = true; - } - - addNewAction() { - this.newAction = { - method: CustomActionMethod.URL, - emails: [], - url: '', - }; - } - - handleAddNewRule() { - this.actionNameError = null; - if (this.newRule.title === '') { - this.actionNameError = 'The name cannot be empty.'; - } else { - const coinsidingName = this.rules.find((rule: Rule) => rule.title === this.newRule.title); - if (!coinsidingName) { - this.selectedRule = { ...this.newRule }; - this.selectedRuleTitle = this.selectedRule.title; - this.rules.push(this.selectedRule); - this.newRule = null; - this.isCreationMode = false; - } else { - this.actionNameError = 'You already have an action with this name.'; - } - } - } - - undoRule() { - if (this.newRule) { - this.rules = this.rules.filter((r) => r !== this.newRule); - } - this.newRule = null; - this.isCreationMode = false; - if (this.rules.length) this.setSelectedRule(this.rules[0]); - } - - handleRemoveRule() { - if (this.selectedRule.id) { - this.openDeleteRuleDialog(); - } else { - this.removeRuleFromLocalList(this.selectedRule.title); - } - } - - removeRuleFromLocalList(ruleTitle: string) { - this.rules = this.rules.filter((rule: Rule) => rule.title !== ruleTitle); - if (this.rules.length) this.setSelectedRule(this.rules[0]); - } - - getRules() { - return this._tables.fetchRules(this.connectionID, this.tableName).toPromise(); - } - - handleRuleSubmitting() { - if (!this.selectedRuleTitle) { - this.ruleNameInput?.nativeElement?.focus(); - return; - } - - if (!this.hasTriggersSelected) { - const triggerCard = document.querySelector('.creation-card') as HTMLElement; - if (triggerCard) { - triggerCard.scrollIntoView({ behavior: 'smooth', block: 'center' }); - triggerCard.classList.add('creation-card--highlight'); - setTimeout(() => triggerCard.classList.remove('creation-card--highlight'), 1500); - } - return; - } - - const action = this.selectedRule.table_actions[0]; - if (action) { - const method = (action.method as string).toUpperCase(); - if (method === 'URL' && !action.url) { - (document.querySelector('input[name="action-url"]') as HTMLInputElement)?.focus(); - return; - } - if (method === 'EMAIL' && (!action.emails || action.emails.length === 0)) { - (document.querySelector('mat-select[name="notification-emails"]') as HTMLElement)?.click(); - return; - } - if (method === 'SLACK' && !(action as any).slack_url) { - (document.querySelector('input[name="action-slack-url"]') as HTMLInputElement)?.focus(); - return; - } - } - - if (this.selectedRule.events.filter((event) => event.event !== null).length > 0) { - if (this.selectedRule.id) { - this.updateRule(); - } else { - this.addRule(); - } - } - } - - addRule() { - this.submitting = true; - if (this.selectedRuleTitle) this.selectedRule.title = this.selectedRuleTitle; - this.newRule = null; - this.selectedRule.events = this.selectedRule.events.filter((event) => event.event !== null); - this.selectedRule.events = this.selectedRule.events.map((event) => { - if (event.event === 'CUSTOM') { - return { ...event, ...this.selectedRuleCustomEvent }; - } - return event; - }); - - this._tables.saveRule(this.connectionID, this.tableName, this.selectedRule).subscribe( - async (res) => { - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Rules: rule is saved successfully', - }); - posthog.capture('Rules: rule is saved successfully'); - try { - const undatedRulesData = await this.getRules(); - this.rules = undatedRulesData.action_rules; - const currentRule = this.rules.find((rule: Rule) => rule.id === res.id); - this.setSelectedRule(currentRule); - this.isSaved = true; - } catch (error) { - if (error instanceof HttpErrorResponse) { - console.log(error.error.message); - } else { - throw error; - } - } - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } - - updateRule() { - this.submitting = true; - this.selectedRule.events = this.selectedRule.events.filter((event) => event.event !== null); - if (this.selectedRuleTitle) this.selectedRule.title = this.selectedRuleTitle; - this.selectedRule.events = this.selectedRule.events.map((event) => { - if (event.event === 'CUSTOM') { - return { ...event, ...this.selectedRuleCustomEvent }; - } - return event; - }); - - this._tables.updateRule(this.connectionID, this.tableName, this.selectedRule).subscribe( - async (res) => { - this.submitting = false; - try { - const undatedRulesData = await this.getRules(); - this.rules = undatedRulesData.action_rules; - const currentRule = this.rules.find((rule: Rule) => rule.id === res.id); - this.setSelectedRule(currentRule); - this.isSaved = true; - } catch (error) { - if (error instanceof HttpErrorResponse) { - console.log(error.error.message); - } else { - throw error; - } - } - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } - - openDeleteRuleDialog() { - this.dialog.open(ActionDeleteDialogComponent, { - width: '25em', - data: { - connectionID: this.connectionID, - tableName: this.tableName, - rule: this.selectedRule, - }, - }); - } - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message); - } - - onEventChange(event: any) { - console.log(this.selectedRule.events); - this.selectedEvents.push(event.value); - - let customEvent = this.selectedRule.events.find((event) => event.event === EventType.Custom); - - if (event.value === EventType.Custom) { - customEvent = { - ...customEvent, - title: '', - type: CustomActionType.Single, - icon: '', - require_confirmation: false, - }; - // this.selectedRule.events.push(customEvent); - this.selectedRuleCustomEvent = customEvent; - } - - if (this.selectedRule.events.length < 4) { - this.selectedRule.events.push({ event: null }); - } - } - - toggleTriggerTile(eventType: EventType) { - this.isSaved = false; - const idx = this.selectedEvents.indexOf(eventType); - if (idx > -1) { - // Remove trigger - this.selectedRule.events = this.selectedRule.events.filter((e) => e.event !== eventType); - this.selectedEvents = this.selectedEvents.filter((e) => e !== eventType); - if (eventType === EventType.Custom) { - this.selectedRuleCustomEvent = null; - } - } else { - // Add trigger - this.selectedRule.events = this.selectedRule.events.filter((e) => e.event !== null); - this.selectedRule.events.push({ event: eventType }); - this.selectedEvents.push(eventType); - if (eventType === EventType.Custom) { - this.selectedRuleCustomEvent = { - event: EventType.Custom, - title: '', - type: CustomActionType.Single, - icon: '', - require_confirmation: false, - }; - } - } - // Keep trailing null for the old form - if (this.selectedRule.events[this.selectedRule.events.length - 1]?.event !== null) { - this.selectedRule.events.push({ event: null }); - } - } - - selectActionMethod(method: string) { - this.isSaved = false; - if (this.selectedRule.table_actions.length) { - this.selectedRule.table_actions[0].method = method as CustomActionMethod; - } - } - - removeEvent(event: any) { - this.selectedRule.events = this.selectedRule.events.filter((e) => e.event !== event); - this.selectedEvents = this.selectedRule.events.map((event) => event.event); - - if (event === EventType.Custom) { - this.selectedRuleCustomEvent = null; - } - - if (this.selectedRule.events.length === 3) { - this.selectedRule.events.push({ event: null }); - } - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css deleted file mode 100644 index 6882fbdeb..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css +++ /dev/null @@ -1,941 +0,0 @@ -.ai-panel-sidebar { - position: relative; - opacity: 0; - transform: translateX(100%); - height: calc(100vh - 44px); - width: 0; - overflow-y: auto; - transition: - width 500ms cubic-bezier(0.4, 0, 0.2, 1), - transform 500ms cubic-bezier(0.4, 0, 0.2, 1), - opacity 400ms cubic-bezier(0.4, 0, 0.2, 1); -} - -@media (width <= 600px) { - .ai-panel-sidebar { - width: 100vw; - z-index: 10; - } -} - -.ai-panel-sidebar_open { - opacity: 1; - transform: translateX(24px); - width: clamp(200px, 22vw, 400px); - transition: - transform 400ms cubic-bezier(0.4, 0, 0.2, 1), - opacity 400ms cubic-bezier(0.4, 0, 0.2, 1), - width 400ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.ai-panel-sidebar_open.ai-panel-sidebar_expanded { - /* Keep the same width as collapsed - don't push the table */ - width: clamp(200px, 22vw, 400px); - transition: - transform 400ms cubic-bezier(0.4, 0, 0.2, 1), - opacity 400ms cubic-bezier(0.4, 0, 0.2, 1), - width 400ms cubic-bezier(0.4, 0, 0.2, 1); -} - -@media (width <= 600px) { - .ai-panel-sidebar_open { - width: 0; - } -} - -.ai-panel-sidebar-content { - position: fixed; - top: 44px; - left: 100vw; - display: flex; - flex-direction: column; - background-color: var(--mat-sidenav-content-background-color); - border-left: solid 1px rgba(0, 0, 0, 0.12); - height: calc(100vh - 44px); - width: clamp(200px, 22vw, 400px); - z-index: 100; - transition: - width 500ms cubic-bezier(0.4, 0, 0.2, 1), - border-left 400ms ease; - visibility: hidden; -} - -.ai-panel-sidebar-content_open { - left: calc(100vw - clamp(200px, 22vw, 400px)); - visibility: visible; - transition: - left 400ms cubic-bezier(0.4, 0, 0.2, 1), - width 400ms cubic-bezier(0.4, 0, 0.2, 1), - padding-left 400ms cubic-bezier(0.4, 0, 0.2, 1), - border-left 300ms ease; -} - -.ai-panel-sidebar-content_open.ai-panel-sidebar-content_expanded { - left: 65px; - width: calc(100vw - 65px); - padding-left: 0; - border-left: none; - z-index: 100; - transition: - left 400ms cubic-bezier(0.4, 0, 0.2, 1), - width 400ms cubic-bezier(0.4, 0, 0.2, 1), - padding-left 400ms cubic-bezier(0.4, 0, 0.2, 1), - border-left 300ms ease; -} - -.ai-panel-sidebar-content_open.ai-panel-sidebar-content_expanded.ai-panel-sidebar-content_sidebar-collapsed { - padding-left: 0; -} - -@media (width <= 800px) { - .ai-panel-sidebar-content { - width: 100%; - } - - .ai-panel-sidebar-content_open { - left: 0; - } - - .ai-panel-sidebar-content_open.ai-panel-sidebar-content_expanded { - left: 0; - width: 100%; - padding-left: 0; - } -} - -.ai-panel-sidebar-content ::ng-deep .ai-message { - overflow-x: auto; -} - -.ai-panel-sidebar-content ::ng-deep .ai-message table { - min-width: 100%; - width: max-content; - border-collapse: collapse; - margin: 8px 0; - font-size: 12px; -} - -.ai-panel-sidebar-content ::ng-deep .ai-message table thead { - background-color: rgba(0, 0, 0, 0.04); -} - -@media (prefers-color-scheme: dark) { - .ai-panel-sidebar-content ::ng-deep .ai-message table thead { - background-color: rgba(255, 255, 255, 0.04); - } -} - -.ai-panel-sidebar-content ::ng-deep .ai-message table th, -.ai-panel-sidebar-content ::ng-deep .ai-message table td { - padding: 6px 8px; - text-align: left; - border: 1px solid rgba(0, 0, 0, 0.12); - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .ai-panel-sidebar-content ::ng-deep .ai-message table th, - .ai-panel-sidebar-content ::ng-deep .ai-message table td { - border: 1px solid rgba(255, 255, 255, 0.12); - } -} - -.ai-panel-sidebar-content ::ng-deep .ai-message table th { - font-weight: 600; -} - -.ai-panel-sidebar-content ::ng-deep .ai-message table tbody tr:hover { - background-color: rgba(0, 0, 0, 0.02); -} - -@media (prefers-color-scheme: dark) { - .ai-panel-sidebar-content ::ng-deep .ai-message table tbody tr:hover { - background-color: rgba(255, 255, 255, 0.02); - } -} - -@media (prefers-color-scheme: dark) { - .ai-panel-sidebar-content { - border-left: 1px solid var(--mat-sidenav-container-divider-color); - } -} - -@media (width <= 600px) { - .ai-panel-sidebar-content { - left: 0; - width: 100vw; - z-index: 5; - } -} - -.ai-panel-sidebar__header { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 20px; - padding-left: 16px; - padding-right: 16px; - flex-shrink: 0; - transition: - max-width 350ms cubic-bezier(0.4, 0, 0.2, 1), - padding 300ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.ai-panel-sidebar__actions { - display: flex; - align-items: center; - transition: - right 350ms cubic-bezier(0.4, 0, 0.2, 1), - top 350ms cubic-bezier(0.4, 0, 0.2, 1); -} - -@media (prefers-color-scheme: dark) { - .ai-icon ::ng-deep svg path { - fill: #fff; - } -} - -.ai-icon { - animation: rocket-float 3s ease-in-out infinite; -} - -.ai-icon ::ng-deep svg path[fill="#C177FC"] { - animation: sparkle-twinkle 1.5s ease-in-out infinite; -} - -.ai-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(2) { - animation-delay: 0.3s; -} - -.ai-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(3) { - animation-delay: 0.6s; -} - -.ai-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(4) { - animation-delay: 0.9s; -} - -@keyframes rocket-float { - 0%, - 100% { - transform: translateY(0) rotate(0deg); - } - 50% { - transform: translateY(-2px) rotate(2deg); - } -} - -@keyframes sparkle-twinkle { - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.4; - transform: scale(0.8); - } -} - -.ai-panel-sidebar__title { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 0 !important; -} - -.ai-panel-chat { - display: flex; - flex-direction: column; - flex: 1; - justify-content: flex-end; - padding-left: 16px; - padding-right: 16px; - overflow: hidden; - min-height: 0; -} - -.ai-message-chain-box { - height: calc(100vh - 44px - 252px - 48px); - overflow-y: auto; - margin-bottom: 24px; - transition: max-width 350ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.ai-message-chain { - display: flex; - flex-direction: column; - justify-content: flex-end; - min-height: 100%; - width: 100%; -} - -.user-message { - align-self: flex-end; - background-color: #f0f4f8; - border-radius: 8px; - padding: 12px 16px 16px; - margin: 4px 0 16px; - max-width: 80%; -} - -@media (prefers-color-scheme: dark) { - .user-message { - background-color: #2d3748; - } -} - -.ai-message { - align-self: flex-start; - background-color: rgba(99, 132, 255, 0.06); - border-radius: 8px; - padding: 12px 16px 8px; - margin: 4px 0; -} - -@media (prefers-color-scheme: dark) { - .ai-message { - background-color: rgba(99, 132, 255, 0.1); - } -} - -.ai-thinking { - font-size: 12px; - line-height: 1.5; - color: rgba(0, 0, 0, 0.5); - font-style: italic; - white-space: pre-wrap; - word-break: break-word; -} - -@media (prefers-color-scheme: dark) { - .ai-thinking { - color: rgba(255, 255, 255, 0.5); - } -} - -.ai-error-message { - border-radius: 8px; - padding: 8px 8px 0; - margin: 4px 0; -} - -@media (prefers-color-scheme: light) { - .ai-error-message { - background-color: var(--color-warnPalette-100); - color: var(--color-warnPalette-100-contrast); - } -} - -@media (prefers-color-scheme: dark) { - .ai-error-message { - background-color: var(--color-warnDarkPalette-200); - color: var(--color-warnDarkPalette-200-contrast); - } -} - -.ai-message ::ng-deep ol, -.ai-message ::ng-deep ul { - padding-left: 20px; - margin: 8px 0; -} - -.ai-message ::ng-deep li { - margin: 4px 0; - line-height: 1.5; -} - -.ai-message ::ng-deep ol { - list-style-position: outside; -} - -.ai-message ::ng-deep ul { - list-style-position: outside; -} - -.ai-message ::ng-deep pre { - white-space: break-spaces; -} - -.user-message ::ng-deep p, -.ai-message ::ng-deep p { - margin-bottom: 8px; -} - -.ai-welcome { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 24px; - flex-shrink: 0; - transition: max-width 350ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.ai-welcome__message { - display: flex; - flex-direction: column; - gap: 4px; -} - -.ai-welcome__message p { - margin: 0; - line-height: 1.4; -} - -.ai-welcome__section { - display: flex; - flex-direction: column; - gap: 2px; -} - -.ai-welcome__section-title { - margin: 0; - font-size: 12px; - color: rgba(0, 0, 0, 0.5); -} - -@media (prefers-color-scheme: dark) { - .ai-welcome__section-title { - color: rgba(255, 255, 255, 0.5); - } -} - -.suggestions-container { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.suggestion-chip { - display: inline-flex; - align-items: center; - background-color: var(--mat-sidenav-content-background-color); - border: 1px solid #d3d3d3; - border-radius: 16px; - padding: 4px 12px; - font-size: 12px; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.suggestion-chip:hover { - background-color: var(--color-primaryPalette-50); -} - -@media (prefers-color-scheme: dark) { - .suggestion-chip { - border-color: #4a4a4a; - color: rgba(255, 255, 255, 0.9); - } - - .suggestion-chip:hover { - background-color: rgba(255, 255, 255, 0.08); - } -} - -.templates-container { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - padding: 0; - transition: gap 300ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.template-card { - display: flex; - align-items: flex-start; - width: auto; - gap: 8px; - background-color: var(--mat-sidenav-content-background-color); - border: 1px solid #d3d3d3; - border-radius: 6px; - padding: 6px 10px; - text-align: left; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.template-card:hover { - background-color: var(--color-primaryPalette-50); -} - -.template-card__icon { - color: rgba(0, 0, 0, 0.5); - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-top: 1px; -} - -.template-card__content { - display: flex; - flex-direction: column; - gap: 0; -} - -.template-card__title { - font-size: 13px; - font-weight: 500; - color: rgba(0, 0, 0, 0.87); - line-height: 1.3; -} - -.template-card__description { - font-size: 11px; - color: rgba(0, 0, 0, 0.5); - line-height: 1.3; -} - -@media (prefers-color-scheme: dark) { - .template-card { - border-color: #4a4a4a; - } - - .template-card:hover { - background-color: rgba(255, 255, 255, 0.08); - } - - .template-card__icon { - color: rgba(255, 255, 255, 0.5); - } - - .template-card__title { - color: rgba(255, 255, 255, 0.9); - } - - .template-card__description { - color: rgba(255, 255, 255, 0.5); - } -} - -.loading { - display: flex; - justify-content: center; - align-items: center; - height: 50px; - flex-shrink: 0; -} - -.loading-content { - display: flex; - align-items: center; - gap: 8px; -} - -.loading-dots { - display: flex; - gap: 6px; - align-items: center; -} - -.loading-text { - font-size: 13px; - color: #9ca3af; -} - -.loading-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background-color: #9ca3af; - animation: loading-bounce 1.4s ease-in-out infinite; -} - -.loading-dot:nth-child(1) { - animation-delay: 0s; -} - -.loading-dot:nth-child(2) { - animation-delay: 0.15s; -} - -.loading-dot:nth-child(3) { - animation-delay: 0.3s; -} - -@keyframes loading-bounce { - 0%, - 60%, - 100% { - transform: translateY(0); - } - 30% { - transform: translateY(-8px); - } -} - -.ai-message-form { - position: relative; - width: 100%; - flex-shrink: 0; - transition: max-width 350ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.ai-message-form__textarea { - width: 100%; -} - -.ai-message-form__textarea ::ng-deep .mat-mdc-form-field-focus-overlay { - display: none; -} - -.ai-message-form__textarea ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, -.ai-message-form__textarea ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { - border-color: #a855f7 !important; - border-width: 2px !important; -} - -.ai-message-form__textarea ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { - border-color: #a855f7 !important; - border-width: 2px 0 !important; -} - -.ai-message-form__textarea ::ng-deep .mat-mdc-text-field-wrapper { - padding-bottom: 24px !important; -} - -.ai-message-form__footer { - position: absolute; - right: 0; - left: 2px; - bottom: 21px; - display: flex; - justify-content: space-between; - align-items: center; - background-color: var(--mat-sidenav-content-background-color); - padding-left: 16px; - padding-right: 4px; - width: calc(100% - 4px); -} - -.ai-message-form__char-count { - color: rgba(0, 0, 0, 0.4); - font-size: 12px; -} - -@media (prefers-color-scheme: dark) { - .ai-message-form__char-count { - color: rgba(255, 255, 255, 0.4); - } -} - -.ai-message-form__button { - color: #a855f7 !important; -} - -.ai-panel-sidebar__message { - resize: none; - width: 100%; -} - -.footer { - padding-bottom: 16px; - flex-shrink: 0; - transition: max-width 350ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.footer p { - color: rgba(0, 0, 0, 0.36) !important; - text-align: center; - margin: 0; -} - -@media (prefers-color-scheme: dark) { - .footer p { - color: rgba(255, 255, 255, 0.36) !important; - } -} - -.link { - color: var(--color-accentedPalette-500); -} - -/* Expanded state styles */ -.ai-panel-sidebar-content_expanded .ai-panel-chat { - align-items: center; -} - -.ai-panel-sidebar-content_expanded .ai-welcome { - width: 100%; - max-width: 800px; -} - -.ai-panel-sidebar-content_expanded .ai-message-chain-box { - width: 100%; - max-width: 800px; -} - -.ai-panel-sidebar-content_expanded .ai-message-form { - width: 100%; - max-width: 800px; -} - -.ai-panel-sidebar-content_expanded .footer { - width: 100%; - max-width: 800px; -} - -.ai-panel-sidebar-content_expanded .ai-panel-sidebar__header { - width: 100%; - justify-content: space-between; -} - -.ai-panel-sidebar-content_expanded .ai-welcome__section { - gap: 4px !important; -} - -.ai-panel-sidebar-content_expanded .ai-welcome__section-title { - margin-bottom: 0 !important; -} - -.ai-panel-sidebar-content_expanded .templates-container, -.ai-panel-sidebar-content_expanded .suggestions-container { - margin-top: 0 !important; -} - -.ai-panel-sidebar-content_expanded .templates-container { - flex-direction: row; - flex-wrap: wrap; - gap: 12px; -} - -.ai-panel-sidebar-content_expanded .template-card { - width: auto; -} - -.ai-panel-sidebar-content_expanded .template-card__description { - white-space: nowrap; -} - -/* Welcome centered layout */ -.ai-welcome-centered { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - padding: 16px; - min-height: 0; - overflow: hidden; -} - -.ai-welcome-centered__content { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 500px; - gap: 24px; -} - -.ai-welcome-icon { - width: 48px !important; - height: 48px !important; - animation: rocket-float 3s ease-in-out infinite; -} - -.ai-welcome-icon ::ng-deep svg path:first-of-type { - fill: rgba(0, 0, 0, 0.6); -} - -.ai-welcome-icon ::ng-deep svg path[fill="#C177FC"] { - animation: sparkle-twinkle 1.5s ease-in-out infinite; -} - -.ai-welcome-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(2) { - animation-delay: 0.3s; -} - -.ai-welcome-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(3) { - animation-delay: 0.6s; -} - -.ai-welcome-icon ::ng-deep svg path[fill="#C177FC"]:nth-of-type(4) { - animation-delay: 0.9s; -} - -@media (prefers-color-scheme: dark) { - .ai-welcome-icon ::ng-deep svg path:first-of-type { - fill: rgba(255, 255, 255, 0.7); - } -} - -.ai-welcome-centered__title { - font-size: 16px; - font-weight: 500; - color: rgba(0, 0, 0, 0.7); - margin: 0; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .ai-welcome-centered__title { - color: rgba(255, 255, 255, 0.7); - } -} - -.ai-input-wrapper { - position: relative; - width: 100%; -} - -.ai-welcome-form { - width: 100%; -} - -.ai-welcome-form__field { - width: 100%; -} - -.ai-welcome-form__field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading, -.ai-welcome-form__field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { - border-radius: 24px; -} - -.ai-welcome-form__field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading { - border-radius: 24px 0 0 24px; - width: 24px; -} - -.ai-welcome-form__field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { - border-radius: 0 24px 24px 0; -} - -.ai-welcome-form__field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, -.ai-welcome-form__field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { - border-color: #6384ff !important; - border-width: 2px !important; -} - -.ai-welcome-form__field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { - border-color: #6384ff !important; - border-width: 2px 0 !important; -} - -.ai-welcome-form__field ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -.ai-welcome-form__button { - color: #6384ff !important; -} - -.ai-completions { - position: absolute; - top: 100%; - left: 0; - right: 0; - background-color: var(--mat-sidenav-content-background-color); - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - z-index: 10; - margin-top: 2px; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .ai-completions { - border-color: rgba(255, 255, 255, 0.12); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - } -} - -.ai-completion-item { - display: block; - width: 100%; - padding: 12px 16px; - text-align: left; - background: none; - border: none; - cursor: pointer; - font-size: 13px; - color: rgba(0, 0, 0, 0.87); - transition: background-color 0.15s ease; -} - -.ai-completion-item:hover { - background-color: rgba(99, 132, 255, 0.08); -} - -.ai-completion-item:not(:last-child) { - border-bottom: 1px solid rgba(0, 0, 0, 0.06); -} - -@media (prefers-color-scheme: dark) { - .ai-completion-item { - color: rgba(255, 255, 255, 0.87); - } - - .ai-completion-item:not(:last-child) { - border-bottom-color: rgba(255, 255, 255, 0.06); - } -} - -.ai-categories { - display: flex; - flex-direction: column; - gap: 0; - width: 100%; -} - -.ai-category { - display: flex; - flex-direction: column; - gap: 0; -} - -.ai-category__title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(0, 0, 0, 0.4); - margin: 12px 0 4px 0 !important; -} - -.ai-category:first-child .ai-category__title { - margin-top: 0; -} - -@media (prefers-color-scheme: dark) { - .ai-category__title { - color: rgba(255, 255, 255, 0.4); - } -} - -.ai-category__chips { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.suggestion-chip_active { - background-color: rgba(99, 132, 255, 0.15) !important; - border-color: #6384ff !important; -} - -@media (prefers-color-scheme: dark) { - .suggestion-chip_active { - background-color: rgba(99, 132, 255, 0.25) !important; - } -} - -@media (prefers-color-scheme: dark) { - .ai-message ::ng-deep svg text, - .ai-message ::ng-deep svg .legend text, - .ai-message ::ng-deep svg .title text, - .ai-message ::ng-deep svg .label text, - .ai-message ::ng-deep svg .tick text, - .ai-message ::ng-deep svg .axis text, - .ai-message ::ng-deep svg .pieCircle text, - .ai-message ::ng-deep svg .slice text { - fill: rgba(255, 255, 255, 0.87) !important; - } - - .ai-message ::ng-deep svg .title { - fill: rgba(255, 255, 255, 0.87) !important; - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html deleted file mode 100644 index e7faaa311..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html +++ /dev/null @@ -1,119 +0,0 @@ -
-
-
-
-

AI insights

-
- -
-
- -
-
-
-
- {{message.text}} - -
{{ message.thinking }}
- -
-
-
-
- - -
-
- -

Ask anything about your data

- -
-
- - Ask a question about this table... - - - -
- -
- -
-
- -
-
-

{{category.title}}

-
- -
-
-
-
-
-
- -
-
- {{ currentLoadingStep }} -
- - - -
-
-
- -
- - Ask a question about this table... - - - - -
- - -
-
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts deleted file mode 100644 index c9553277d..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { AiService } from 'src/app/services/ai.service'; -import { AiStreamChunk } from 'src/app/services/api.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DbTableAiPanelComponent } from './db-table-ai-panel.component'; - -async function* mockStream(...texts: string[]): AsyncGenerator { - for (const text of texts) { - yield { type: 'text', content: text }; - } -} - -describe('DbTableAiPanelComponent', () => { - let component: DbTableAiPanelComponent; - let fixture: ComponentFixture; - let tableStateService: TableStateService; - - const mockConnectionsService = { - currentConnectionID: '12345678', - }; - - const mockTablesService = { - currentTableName: 'users', - }; - - const mockAiService: Partial = { - createThread: vi.fn(), - sendMessage: vi.fn(), - }; - - const mockTableStateService = { - aiPanelCast: of(false), - aiPanelExpandedCast: of(false), - handleViewAIpanel: vi.fn(), - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [Angulartics2Module.forRoot(), DbTableAiPanelComponent, BrowserAnimationsModule, MatIconTestingModule], - providers: [ - provideHttpClient(), - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: TablesService, useValue: mockTablesService }, - { provide: AiService, useValue: mockAiService }, - { provide: TableStateService, useValue: mockTableStateService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DbTableAiPanelComponent); - component = fixture.componentInstance; - tableStateService = TestBed.inject(TableStateService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with connection ID and table name', () => { - expect(component.connectionID).toBe('12345678'); - expect(component.tableName).toBe('users'); - }); - - it('should have default suggestion categories', () => { - expect(component.suggestionCategories.length).toBeGreaterThan(0); - expect(component.suggestionCategories[0].title).toBe('Explore'); - }); - - it('should update character count on keydown', () => { - component.message = 'Hello'; - const event = new KeyboardEvent('keydown', { key: 'a' }); - component.onKeydown(event); - expect(component.charactrsNumber).toBe(6); - }); - - it('should create thread on Enter key when no thread exists', () => { - (mockAiService.createThread as ReturnType).mockResolvedValue({ - threadId: 'thread-123', - stream: mockStream('AI response'), - }); - - component.message = 'Test message'; - component.threadID = null; - - const event = new KeyboardEvent('keydown', { key: 'Enter' }); - Object.defineProperty(event, 'preventDefault', { value: vi.fn() }); - component.onKeydown(event); - - expect(mockAiService.createThread).toHaveBeenCalledWith( - '12345678', - 'users', - 'Test message', - expect.any(AbortSignal), - ); - }); - - it('should send message on Enter key when thread exists', () => { - (mockAiService.sendMessage as ReturnType).mockResolvedValue(mockStream('AI response')); - - component.message = 'Follow up message'; - component.threadID = 'existing-thread'; - - const event = new KeyboardEvent('keydown', { key: 'Enter' }); - Object.defineProperty(event, 'preventDefault', { value: vi.fn() }); - component.onKeydown(event); - - expect(mockAiService.sendMessage).toHaveBeenCalledWith( - '12345678', - 'users', - 'existing-thread', - 'Follow up message', - expect.any(AbortSignal), - ); - }); - - it('should add user message to chain when creating thread', async () => { - (mockAiService.createThread as ReturnType).mockResolvedValue({ - threadId: 'thread-123', - stream: mockStream('AI response'), - }); - - component.message = 'User question'; - await component.createThread(); - - expect(component.messagesChain[0]).toEqual({ - type: 'user', - text: 'User question', - }); - }); - - it('should add AI response to chain after thread creation', async () => { - (mockAiService.createThread as ReturnType).mockResolvedValue({ - threadId: 'thread-123', - stream: mockStream('AI response'), - }); - - component.message = 'User question'; - await component.createThread(); - - expect(component.threadID).toBe('thread-123'); - expect(component.messagesChain[1]).toEqual({ - type: 'ai', - text: 'AI response', - thinking: '', - }); - }); - - it('should handle error when creating thread', async () => { - (mockAiService.createThread as ReturnType).mockRejectedValue('Error message'); - - component.message = 'User question'; - await component.createThread(); - - expect(component.messagesChain[1]).toEqual({ - type: 'ai-error', - text: 'Error message', - }); - }); - - it('should use suggested message when provided to createThread', async () => { - (mockAiService.createThread as ReturnType).mockResolvedValue({ - threadId: 'thread-123', - stream: mockStream('AI response'), - }); - - await component.createThread('Suggested question'); - - expect(component.messagesChain[0].text).toBe('Suggested question'); - }); - - it('should call handleViewAIpanel when closing', () => { - component.handleClose(); - expect(mockTableStateService.handleViewAIpanel).toHaveBeenCalled(); - }); - - it('should clear message after sending', async () => { - (mockAiService.sendMessage as ReturnType).mockResolvedValue(mockStream('AI response')); - - component.message = 'Test message'; - component.threadID = 'thread-123'; - await component.sendMessage(); - - expect(component.message).toBe(''); - expect(component.charactrsNumber).toBe(0); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts deleted file mode 100644 index a99d1d9cc..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - AfterViewInit, - Component, - ElementRef, - HostListener, - Input, - inject, - OnDestroy, - OnInit, - ViewChild, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { MarkdownModule } from 'ngx-markdown'; -import posthog from 'posthog-js'; -import { AiService } from 'src/app/services/ai.service'; -import { AiStreamChunk } from 'src/app/services/api.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; - -@Component({ - selector: 'app-db-table-ai-panel', - templateUrl: './db-table-ai-panel.component.html', - styleUrl: './db-table-ai-panel.component.css', - imports: [ - CommonModule, - FormsModule, - MarkdownModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - Angulartics2Module, - ], -}) -export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy { - @Input() public displayName: string; - @Input() public tableColumns: string[] = []; - @Input() public sidebarExpanded: boolean = true; - @ViewChild('chatContainer') private chatContainer!: ElementRef; - - public connectionID: string; - public tableName: string; - public isAIpanelOpened: boolean = false; - public message: string = ''; - public charactrsNumber: number = 0; - public threadID: string = null; - public messagesChain: { - type: string; - text: string; - thinking?: string; - }[] = []; - public aiSuggestions: { title: string; prompt: string; completions: string[] }[] = []; - public suggestionCategories: { - title: string; - suggestions: { title: string; prompt: string; completions: string[] }[]; - }[] = []; - public activeSuggestion: { title: string; prompt: string; completions: string[] } | null = null; - public activeCompletions: string[] = []; - public showCompletions: boolean = false; - public submitting: boolean = false; - public isExpanded: boolean = true; - public textareaRows: number = 4; - public currentLoadingStep: string = ''; - - private _aiService = inject(AiService); - private _abortController: AbortController | null = null; - private _loadingStepsInterval: ReturnType | null = null; - private _loadingSteps: string[] = [ - 'Connecting to database', - 'Analyzing table structure', - 'Scanning records', - 'Processing your query', - 'Searching for patterns', - 'Generating response', - ]; - private _currentStepIndex: number = 0; - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private _tableState: TableStateService, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this.tableName = this._tables.currentTableName; - - this._tableState.aiPanelCast.subscribe((isAIpanelOpened) => { - this.isAIpanelOpened = isAIpanelOpened; - }); - - this._tableState.aiPanelExpandedCast.subscribe(() => { - this.isExpanded = true; - }); - - this.adjustTextareaRows(); - this.generateSuggestionCategories(); - } - - ngOnChanges(): void { - this.generateSuggestionCategories(); - } - - async ngAfterViewInit() { - const mermaid = await import('mermaid'); - const mermaidAPI = (mermaid.default ?? mermaid) as { initialize: (config: Record) => void }; - const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - mermaidAPI.initialize({ - startOnLoad: false, - theme: isDark ? 'dark' : 'default', - }); - //@ts-expect-error dynamic load of mermaid - window.mermaid = mermaidAPI; - } - - ngOnDestroy() { - this.cancelRequest(); - this.stopLoadingSteps(); - this.angulartics2.eventTrack.next({ - action: 'AI panel: destroyed', - properties: { - messagesLength: this.messagesChain.length, - }, - }); - posthog.capture('AI panel: destroyed', { - messagesLength: this.messagesChain.length, - }); - } - - cancelRequest(): void { - if (this._abortController) { - this._abortController.abort(); - this._abortController = null; - this.submitting = false; - this.stopLoadingSteps(); - - this.messagesChain.push({ - type: 'ai-error', - text: 'Request cancelled', - }); - - this.angulartics2.eventTrack.next({ - action: 'AI panel: request cancelled', - }); - posthog.capture('AI panel: request cancelled'); - } - } - - startLoadingSteps(): void { - this._currentStepIndex = 0; - this.currentLoadingStep = this._loadingSteps[0]; - this._loadingStepsInterval = setInterval(() => { - this._currentStepIndex = (this._currentStepIndex + 1) % this._loadingSteps.length; - this.currentLoadingStep = this._loadingSteps[this._currentStepIndex]; - }, 2000); - } - - stopLoadingSteps(): void { - if (this._loadingStepsInterval) { - clearInterval(this._loadingStepsInterval); - this._loadingStepsInterval = null; - } - } - - onKeydown(event: KeyboardEvent): void { - this.charactrsNumber = this.message.length + 1; - - if (event.key === 'Enter') { - if (event.shiftKey) { - event.preventDefault(); - const textarea = event.target as HTMLTextAreaElement; - const cursorPos = textarea.selectionStart; - this.message = this.message.substring(0, cursorPos) + '\n' + this.message.substring(cursorPos); - setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = cursorPos + 1; - }); - } else { - event.preventDefault(); - if (this.threadID) { - this.sendMessage(); - } else { - this.createThread(); - } - } - } - } - - async createThread(suggestedMessage?: string) { - if (suggestedMessage) { - this.message = suggestedMessage; - } - this.submitting = true; - this.startLoadingSteps(); - this.messagesChain.push({ - type: 'user', - text: this.message, - }); - const messageCopy = this.message; - this.message = ''; - - this._abortController = new AbortController(); - - try { - const response = await this._aiService.createThread( - this.connectionID, - this.tableName, - messageCopy, - this._abortController.signal, - ); - - if (response) { - this.threadID = response.threadId; - const aiMessage = { type: 'ai', text: '', thinking: '' }; - this.messagesChain.push(aiMessage); - this.stopLoadingSteps(); - - await this._consumeStream(response.stream, aiMessage); - - this.angulartics2.eventTrack.next({ - action: 'AI panel: thread created successfully', - }); - posthog.capture('AI panel: thread created successfully'); - } - } catch (error_message) { - this.messagesChain.push({ - type: 'ai-error', - text: String(error_message), - }); - this.angulartics2.eventTrack.next({ - action: 'AI panel: thread creation returned an error', - }); - posthog.capture('AI panel: thread creation returned an error'); - } finally { - this.submitting = false; - this.stopLoadingSteps(); - this._abortController = null; - } - } - - async sendMessage(suggestedMessage?: string) { - if (suggestedMessage) { - this.message = suggestedMessage; - } - this.submitting = true; - this.startLoadingSteps(); - this.messagesChain.push({ - type: 'user', - text: this.message, - }); - const messageCopy = this.message; - this.message = ''; - this.charactrsNumber = 0; - - this._abortController = new AbortController(); - - try { - const stream = await this._aiService.sendMessage( - this.connectionID, - this.tableName, - this.threadID, - messageCopy, - this._abortController.signal, - ); - - if (stream) { - const aiMessage = { type: 'ai', text: '', thinking: '' }; - this.messagesChain.push(aiMessage); - this.stopLoadingSteps(); - - await this._consumeStream(stream, aiMessage); - - this.angulartics2.eventTrack.next({ - action: 'AI panel: message sent successfully', - }); - posthog.capture('AI panel: message sent successfully'); - } - } catch (error_message) { - this.messagesChain.push({ - type: 'ai-error', - text: String(error_message), - }); - this.angulartics2.eventTrack.next({ - action: 'AI panel: message sent and returned an error', - }); - posthog.capture('AI panel: message sent and returned an error'); - } finally { - this.submitting = false; - this.stopLoadingSteps(); - this._abortController = null; - } - } - - private async _consumeStream( - stream: AsyncGenerator, - message: { type: string; text: string; thinking?: string }, - ) { - for await (const chunk of stream) { - if (chunk.type === 'thinking') { - message.thinking = (message.thinking ?? '') + chunk.content; - } else if (chunk.type === 'thinking_reset') { - message.thinking = ''; - } else if (chunk.type === 'thinking_commit') { - message.text += message.thinking ?? ''; - message.thinking = ''; - } else if (chunk.type === 'text') { - message.text += chunk.content; - message.thinking = ''; - } - } - } - - scrollToBottom(): void { - if (this.chatContainer) this.chatContainer.nativeElement.scrollTop = this.chatContainer.nativeElement.scrollHeight; - } - - ngAfterViewChecked(): void { - this.scrollToBottom(); - } - - handleClose() { - this._tableState.handleViewAIpanel(); - } - - toggleExpand() { - this._tableState.toggleAIPanelExpanded(); - } - - @HostListener('window:resize') - onWindowResize() { - this.adjustTextareaRows(); - } - - @HostListener('document:click', ['$event']) - onDocumentClick(event: MouseEvent) { - if (!this.showCompletions) return; - - const target = event.target as HTMLElement; - const clickedOnChip = target.closest('.suggestion-chip'); - const clickedOnDropdown = target.closest('.ai-completions'); - const clickedOnInput = target.closest('.ai-welcome-form__field'); - - if (!clickedOnChip && !clickedOnDropdown && !clickedOnInput) { - this.showCompletions = false; - this.activeSuggestion = null; - } - } - - private adjustTextareaRows() { - const windowHeight = window.innerHeight; - - if (windowHeight < 500) { - this.textareaRows = 1; - } else if (windowHeight < 650) { - this.textareaRows = 2; - } else if (windowHeight < 800) { - this.textareaRows = 3; - } else if (windowHeight < 950) { - this.textareaRows = 4; - } else { - this.textareaRows = 5; - } - } - - private generateSuggestionCategories(): void { - const categories: { title: string; suggestions: { title: string; prompt: string; completions: string[] }[] }[] = []; - - // Category: Explore Data - categories.push({ - title: 'Explore', - suggestions: [ - { - title: 'Show recent records', - prompt: 'Show recent records', - completions: [ - 'Show recent records - the 10 most recently added', - 'Show recent records - from the last 24 hours', - 'Show recent records - the last entry added', - 'Show recent records - with newest timestamps', - ], - }, - { - title: 'Summarize table', - prompt: 'Summarize table', - completions: [ - 'Summarize table - structure and content overview', - 'Summarize table - what kind of data is stored', - 'Summarize table - purpose of each column', - 'Summarize table - record count and what they represent', - ], - }, - { - title: 'Find patterns', - prompt: 'Find patterns', - completions: [ - 'Find patterns - in this data', - 'Find patterns - correlations between columns', - 'Find patterns - common value combinations', - 'Find patterns - seasonal or cyclical trends', - ], - }, - ], - }); - - // Category: Data Quality - categories.push({ - title: 'Data Quality', - suggestions: [ - { - title: 'Find issues', - prompt: 'Find issues', - completions: [ - 'Find issues - data quality problems', - 'Find issues - missing or NULL values', - 'Find issues - potential duplicates', - 'Find issues - inconsistent data formats', - ], - }, - { - title: 'Check duplicates', - prompt: 'Check duplicates', - completions: [ - 'Check duplicates - any duplicate records', - 'Check duplicates - rows that look similar', - 'Check duplicates - values appearing multiple times', - 'Check duplicates - records entered twice', - ], - }, - { - title: 'Validate data', - prompt: 'Validate data', - completions: [ - 'Validate data - required fields filled', - 'Validate data - incorrect values', - 'Validate data - records not matching patterns', - 'Validate data - outliers and unusual values', - ], - }, - ], - }); - - // Category: Insights - categories.push({ - title: 'Insights', - suggestions: [ - { - title: 'Top values', - prompt: 'Top values', - completions: [ - 'Top values - most common in each column', - 'Top values - 10 most frequent entries', - 'Top values - categories with most records', - 'Top values - what appears most often', - ], - }, - { - title: 'Statistics', - prompt: 'Statistics', - completions: [ - 'Statistics - for numeric columns', - 'Statistics - min, max, and average', - 'Statistics - distribution of values', - 'Statistics - summary of this data', - ], - }, - { - title: 'Anomalies', - prompt: 'Anomalies', - completions: [ - 'Anomalies - unusual or unexpected values', - 'Anomalies - records that stand out', - 'Anomalies - statistical outliers', - 'Anomalies - what looks different or wrong', - ], - }, - ], - }); - - this.suggestionCategories = categories; - } - - onSuggestionChipClick(suggestion: { title: string; prompt: string; completions: string[] }): void { - this.activeSuggestion = suggestion; - this.message = suggestion.prompt; - this.activeCompletions = suggestion.completions; - this.showCompletions = true; - - this.angulartics2.eventTrack.next({ - action: 'AI panel: suggestion chip clicked', - properties: { - suggestionTitle: suggestion.title, - }, - }); - posthog.capture('AI panel: suggestion chip clicked', { - suggestionTitle: suggestion.title, - }); - } - - selectCompletion(completion: string): void { - this.message = completion; - this.showCompletions = false; - this.activeSuggestion = null; - this.createThread(completion); - - this.angulartics2.eventTrack.next({ - action: 'AI panel: completion selected', - properties: { - completion: completion, - }, - }); - posthog.capture('AI panel: completion selected', { - completion: completion, - }); - } - - onWelcomeInputChange(): void { - if (this.message.length > 0 && (!this.activeSuggestion || this.message !== this.activeSuggestion.prompt)) { - this.showCompletions = false; - this.activeSuggestion = null; - } - } - - onCompletionHover(completion: string): void { - this.message = completion; - } - - onCompletionMouseLeave(): void { - if (this.activeSuggestion) { - this.message = this.activeSuggestion.prompt; - } - } - - onWelcomeInputFocus(): void { - if (this.activeSuggestion) { - this.showCompletions = true; - } - } - - onWelcomeInputBlur(): void { - // Dropdown closing is now handled by document click listener - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.css deleted file mode 100644 index 2e5ab5ac8..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.records-number-input { - margin-top: 16px; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.html deleted file mode 100644 index 2819d6316..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.html +++ /dev/null @@ -1,31 +0,0 @@ -

Export

-
- -

- Select number of records you want to download. -

- - - - - Number of records - - - - - Export all (up to 100 000 records) - - -
- - - - -
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.spec.ts deleted file mode 100644 index b3c39fd34..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DbTableExportDialogComponent } from './db-table-export-dialog.component'; - -describe('DbTableExportDialogComponent', () => { - let component: DbTableExportDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatDialogModule, - MatSnackBarModule, - FormsModule, - Angulartics2Module.forRoot(), - DbTableExportDialogComponent, - BrowserAnimationsModule, - ], - providers: [ - provideHttpClient(), - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionID: '12345678', - tableName: 'users', - sortColumn: 'first_name', - sortOrder: 'asc', - filters: { first_name: { startswith: 'A' } }, - search: '', - }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DbTableExportDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.ts deleted file mode 100644 index 882d553eb..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-export-dialog/db-table-export-dialog.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { TablesService } from 'src/app/services/tables.service'; - -@Component({ - selector: 'app-db-table-export-dialog', - templateUrl: './db-table-export-dialog.component.html', - styleUrls: ['./db-table-export-dialog.component.css'], - imports: [CommonModule, FormsModule, MatButtonModule, MatDialogModule, MatInputModule, MatRadioModule], -}) -export class DbTableExportDialogComponent { - public recordsNumber: number = 10; - public recordsExportType: string = 'export-all'; - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _tables: TablesService, - public dialogRef: MatDialogRef, - private angulartics2: Angulartics2, - ) {} - - exportCSV() { - this.submitting = true; - if (this.recordsExportType === 'export-all') { - this.data = { ...this.data, chunkSize: 100000 }; - } - - if (this.recordsExportType === 'export-records-number') { - this.data = { ...this.data, chunkSize: this.recordsNumber }; - } - - this._tables.exportTableCSV(this.data).subscribe( - (res) => { - this.downloadCSV(res); - this.submitting = false; - this.dialogRef.close(); - this.angulartics2.eventTrack.next({ - action: 'Dashboard: db export is successful', - }); - posthog.capture('Dashboard: db export is successful'); - }, - (_err) => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - downloadCSV(data) { - const blob = new Blob([data], { type: 'text/csv' }); - const link = document.createElement('a'); - link.href = URL.createObjectURL(blob); - link.download = 'database.csv'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css deleted file mode 100644 index ba76b0eab..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.css +++ /dev/null @@ -1,148 +0,0 @@ -.filters-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 16px; - margin-bottom: 12px; -} - -.filters-header__settings { - flex: 0 0 auto; - margin-left: auto; -} - -.filters-header__text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - line-height: 1.2; - text-align: left; -} - -@media (width <= 600px) { - .filters-header { - justify-content: flex-start; - gap: 12px; - } - - .filters-header__text { - flex: 1 1 auto; - } -} - -.filters-header__label { - font-size: 18px; - font-weight: 600; -} - -.filters-header__table { - font-size: 12px; - font-weight: 500; - opacity: 0.6; -} - -.filters-content { - display: grid; - grid-template-columns: auto 1fr 32px; - grid-column-gap: 8px; - align-content: flex-start; - align-items: flex-start; - padding-top: 8px !important; -} - -.filters-select { - margin-top: 4px; -} - -.filters-select { - grid-column: 1 / -1; -} - -.filter-line { - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; - grid-column-gap: 8px; - align-items: flex-start; -} - -.filter-line__field { - grid-column: 2; - min-width: 0; -} - -::ng-deep .mat-dialog-container > .ng-star-inserted { - display: flex; - flex-direction: column; - width: 100%; -} - -.filters-form { - flex: 1 0 auto; - display: flex; - flex-direction: column; -} - -::ng-deep .mat-dialog-container { - display: flex; -} - -.filters-content { - flex: 1 0 auto; -} - -.column-name { - margin-top: 18px; -} - -.filter-delete-button { - grid-column: 3; - margin-top: 4px; -} - -.filter-line ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -.filter-line { - margin-bottom: 12px; -} - -@media (width <= 600px) { - .filters-content { - grid-template-columns: 1fr 32px; - } - - .filter-line { - grid-template-columns: 1fr 32px; - grid-template-areas: - "name name" - "field button"; - row-gap: 2px; - } - - .column-name { - grid-area: name; - grid-column: 1 / -1; - margin: 0; - font-size: 12px; - font-weight: 600; - opacity: 0.6; - } - - .filter-line__field { - grid-area: field; - grid-column: 1; - } - - .filter-delete-button { - grid-area: button; - grid-column: 2; - margin-top: 0; - } -} - -.settings-form__reset-button { - margin-right: auto; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html deleted file mode 100644 index ba75065cb..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.html +++ /dev/null @@ -1,79 +0,0 @@ -
-

- - Filters - Table: {{ data.displayTableName }} - - - settings - -

- - - - - Add filter by... - - - - {{ field.label }} - - - - - @for (value of tableRowFieldsShown | keyvalue; track value.key) { -
- {{ getFieldLabel(value.key) }} -
- -
- -
- } -
-
- - - - - - -
\ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.spec.ts deleted file mode 100644 index 772470d9c..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.spec.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { DbTableFiltersDialogComponent } from './db-table-filters-dialog.component'; - -describe('DbTableFiltersDialogComponent', () => { - let component: DbTableFiltersDialogComponent; - let fixture: ComponentFixture; - - const fakeFirstName = { - column_name: 'FirstName', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }; - const fakeId = { - column_name: 'Id', - column_default: 'auto_increment', - data_type: 'int', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }; - const fakeBool = { - column_name: 'bool', - column_default: null, - data_type: 'tinyint', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const mockTableDataSource_Structure = [fakeFirstName, fakeId, fakeBool]; - - const mockTableDataSource_ForeignKeysList = ['Id']; - - const mockTableDataSource_ForeignKeys = { - Id: { - referenced_column_name: 'CustomerId', - referenced_table_name: 'Customers', - constraint_name: 'Orders_ibfk_2', - column_name: 'Id', - }, - }; - - const mockStructureForFilterDialog = { - structure: mockTableDataSource_Structure, - foreignKeysList: mockTableDataSource_ForeignKeysList, - foreignKeys: mockTableDataSource_ForeignKeys, - widgets: [], - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - FormsModule, - ReactiveFormsModule, - MatAutocompleteModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - DbTableFiltersDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionID: '12345678', - tableName: 'users', - displayTableName: 'Users', - structure: mockStructureForFilterDialog, - }, - }, - { provide: MatDialogRef, useValue: {} }, - // { provide: ActivatedRoute, useValue: {} } - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableFiltersDialogComponent); - component = fixture.componentInstance; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should setup the initial state of fields list and types', async () => { - component.ngOnInit(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.tableForeignKeys).toEqual(mockStructureForFilterDialog.foreignKeys); - expect(component.fields).toEqual([ - { key: 'FirstName', label: 'FirstName' }, - { key: 'Id', label: 'Id' }, - { key: 'bool', label: 'bool' }, - ]); - expect(component.tableRowStructure).toEqual({ - FirstName: fakeFirstName, - Id: fakeId, - bool: fakeBool, - }); - }); - - it('should setup the initial state from search params', async () => { - component.route.snapshot.queryParams = { - filters: '(FirstName:(contains:nn))', - }; - - component.ngOnInit(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.tableFilters).toEqual(['FirstName']); - expect(component.tableRowFieldsShown).toEqual({ - FirstName: 'nn', - }); - expect(component.tableRowFieldsComparator).toEqual({ - FirstName: 'contains', - }); - }); - - it('should setup the initial state from isSearched table settings', async () => { - component.ngOnInit(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.tableFilters).toEqual(['bool']); - expect(component.tableRowFieldsShown).toEqual({ - bool: undefined, - }); - expect(component.tableRowFieldsComparator).toEqual({ - bool: 'eq', - }); - }); - - it('should update a field in the model from ui component', () => { - component.tableRowFieldsShown = { name: 'John' }; - component.updateField('new user name', 'name'); - - expect(component.tableRowFieldsShown.name).toEqual('new user name'); - }); - - it('should reset filters', () => { - component.tableFilters = ['FirstName', 'Id']; - component.tableRowFieldsShown = { - FirstName: 'John', - Id: '111', - }; - component.resetFilters(); - - expect(component.tableFilters).toEqual([]); - expect(component.tableRowFieldsShown).toEqual({}); - }); - - it('should return number type of comparator select if data type is datetime', () => { - const comparatorType = component.getComparatorType('datetime'); - expect(comparatorType).toEqual('number'); - }); - - it('should return nonComparable type of comparator select if data type neither text nor number', () => { - const comparatorType = component.getComparatorType(undefined); - expect(comparatorType).toEqual('nonComparable'); - }); - - it('should receive comparator from EmailFilterComponent after it renders', async () => { - component.setWidgets([ - { - field_name: 'FirstName', - widget_type: 'Email', - widget_params: '// No settings required', - name: '', - description: '', - }, - ]); - - component.addFilter({ option: { value: 'FirstName' } }); - - // Default comparator is 'eq' from addFilter - expect(component.tableRowFieldsComparator['FirstName']).toEqual('eq'); - - // Trigger change detection so ndc-dynamic renders EmailFilterComponent - fixture.detectChanges(); - await fixture.whenStable(); - - // EmailFilterComponent emits 'eq' (default mode) in ngAfterViewInit, - // which updateComparatorFromComponent receives - expect(component.tableRowFieldsComparator['FirstName']).toEqual('eq'); - }); - - it('should recognize widgets when passed as object (real app format)', () => { - const widgetsObject = { - FirstName: { - field_name: 'FirstName', - widget_type: 'Email', - widget_params: {}, - name: '', - description: '', - }, - }; - - component.data.structure.widgets = widgetsObject; - component.ngOnInit(); - - expect(component.isWidget('FirstName')).toBe(true); - }); - - it('should report fields with active filters as already filtered', () => { - component.tableRowFieldsShown = { FirstName: 'John' }; - expect(component.isFieldAlreadyFiltered('FirstName')).toBe(true); - expect(component.isFieldAlreadyFiltered('Id')).toBe(false); - }); - - it('should use widget name as field label when widget has a name', () => { - component.data.structure.widgets = { - FirstName: { - field_name: 'FirstName', - widget_type: 'Email', - widget_params: {}, - name: 'Customer Email', - description: '', - }, - }; - component.ngOnInit(); - - expect(component.fields).toEqual(expect.arrayContaining([{ key: 'FirstName', label: 'Customer Email' }])); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.ts deleted file mode 100644 index 407f6e218..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-filters-dialog/db-table-filters-dialog.component.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, Inject, KeyValueDiffer, KeyValueDiffers, OnInit, Type } from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, RouterModule } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import { Angulartics2OnModule } from 'angulartics2'; -import JSON5 from 'json5'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import posthog from 'posthog-js'; -import { Observable } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; -import { UIwidgets as FilterUIwidgets, filterTypes } from 'src/app/consts/filter-types'; -import { UIwidgets as EditUIwidgets } from 'src/app/consts/record-edit-types'; -import { getComparatorsFromUrl, getFiltersFromUrl } from 'src/app/lib/parse-filter-params'; -import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; -import { TableField, TableForeignKey, Widget } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { ContentLoaderComponent } from '../../../ui-components/content-loader/content-loader.component'; -import { DefaultFilterComponent } from '../../../ui-components/filter-fields/default-filter/default-filter.component'; - -@Component({ - selector: 'app-db-table-filters-dialog', - templateUrl: './db-table-filters-dialog.component.html', - styleUrls: ['./db-table-filters-dialog.component.css'], - imports: [ - CommonModule, - ReactiveFormsModule, - FormsModule, - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - DynamicModule, - SignalComponentIoModule, - RouterModule, - MatDialogModule, - Angulartics2OnModule, - ContentLoaderComponent, - ], -}) -export class DbTableFiltersDialogComponent implements OnInit, AfterViewInit { - protected posthog = posthog; - public tableFilters = []; - public fieldSearchControl = new FormControl(''); - - public fields: { key: string; label: string }[]; - public foundFields: Observable<{ key: string; label: string }[]>; - - public tableRowFields: Object; - public tableRowStructure: Object; - public tableRowFieldsShown: Record = {}; - public tableRowFieldsComparator: Object = {}; - public tableForeignKeys: { [key: string]: TableForeignKey }; - public tableFiltersCount: number; - public differ: KeyValueDiffer; - public tableTypes: Object; - public tableWidgets: object; - public UIwidgets = { ...EditUIwidgets, ...FilterUIwidgets }; - public autofocusField: string | null = null; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _connections: ConnectionsService, - public route: ActivatedRoute, - private differs: KeyValueDiffers, - ) { - this.differ = this.differs.find({}).create(); - } - - ngOnInit(): void { - this.tableForeignKeys = { ...this.data.structure.foreignKeys }; - this.tableRowFields = Object.assign( - {}, - ...this.data.structure.structure.map((field: TableField) => ({ [field.column_name]: undefined })), - ); - this.tableTypes = getTableTypes(this.data.structure.structure, this.data.structure.foreignKeysList); - this.tableRowStructure = Object.assign( - {}, - ...this.data.structure.structure.map((field: TableField) => { - return { [field.column_name]: field }; - }), - ); - - if (this.data.autofocusField) { - this.autofocusField = this.data.autofocusField; - } - - const queryParams = this.route.snapshot.queryParams; - - if (queryParams.saved_filter) { - this.tableFilters = []; - this.tableRowFieldsShown = {}; - this.tableRowFieldsComparator = {}; - } else { - let filters = {}; - if (queryParams.filters) filters = JsonURL.parse(queryParams.filters); - const filtersValues = getFiltersFromUrl(filters); - - if (Object.keys(filtersValues).length) { - this.tableFilters = Object.keys(filtersValues).map((key) => key); - this.tableRowFieldsShown = filtersValues; - this.tableRowFieldsComparator = getComparatorsFromUrl(filters); - } else { - const fieldsToSearch = this.data.structure.structure.filter((field: TableField) => field.isSearched); - if (fieldsToSearch.length) { - this.tableFilters = fieldsToSearch.map((field: TableField) => field.column_name); - this.tableRowFieldsShown = Object.assign( - {}, - ...fieldsToSearch.map((field: TableField) => ({ [field.column_name]: undefined })), - ); - this.tableRowFieldsComparator = Object.assign( - {}, - ...fieldsToSearch.map((field: TableField) => ({ [field.column_name]: 'eq' })), - ); - } - } - } - - const widgets = this.data.structure.widgets; - const widgetsArray = Array.isArray(widgets) ? widgets : Object.values(widgets || {}); - if (widgetsArray.length) { - this.setWidgets(widgetsArray); - } - - this.fields = this.data.structure.structure - .filter((field: TableField) => this.getInputType(field.column_name) !== 'file') - .map((field: TableField) => ({ - key: field.column_name, - label: this.getFieldLabel(field.column_name), - })); - - if (this.autofocusField && this.tableFilters && !this.tableFilters.includes(this.autofocusField)) { - this.tableFilters.push(this.autofocusField); - if (!this.tableRowFieldsShown[this.autofocusField]) { - this.tableRowFieldsShown[this.autofocusField] = undefined; - } - if (!this.tableRowFieldsComparator[this.autofocusField]) { - this.tableRowFieldsComparator[this.autofocusField] = 'eq'; - } - } - - this.foundFields = this.fieldSearchControl.valueChanges.pipe( - startWith(''), - map((value) => this._filter(value || '')), - ); - } - - ngAfterViewInit(): void { - if (this.autofocusField) { - setTimeout(() => { - this.focusOnField(this.autofocusField); - }, 200); - } - } - - focusOnField(fieldName: string) { - const selectors = [ - `input[name*="${fieldName}"]`, - `textarea[name*="${fieldName}"]`, - `[data-field="${fieldName}"] input`, - `[data-field="${fieldName}"] textarea`, - `mat-form-field:has([name*="${fieldName}"]) input`, - `mat-form-field:has([name*="${fieldName}"]) textarea`, - ]; - - for (const selector of selectors) { - try { - const element = document.querySelector(selector) as HTMLElement; - if (element) { - element.focus(); - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return; - } - } catch (e) { - // Continue to next selector if this one fails - } - } - - // Fallback: try to find by key attribute in ndc-dynamic components - const allInputs = document.querySelectorAll('input, textarea'); - for (let i = 0; i < allInputs.length; i++) { - const input = allInputs[i] as HTMLElement; - const formField = input.closest('mat-form-field'); - if (formField) { - const label = formField.querySelector('mat-label'); - if (label && label.textContent && label.textContent.trim() === fieldName) { - input.focus(); - input.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return; - } - } - } - } - - isFieldAlreadyFiltered(key: string): boolean { - return Object.hasOwn(this.tableRowFieldsShown, key); - } - - getFieldLabel(key: string): string { - return this.tableWidgets?.[key]?.name || key; - } - - getFilterComponent(field: string): Type { - const valueComponent = this.resolveValueComponent(field); - const innerType = (valueComponent as { type?: string })?.type; - if (innerType === 'text' || innerType === 'number' || innerType === 'datetime') { - return DefaultFilterComponent; - } - return valueComponent; - } - - getFilterInputs(field: string): Record { - const valueComponent = this.resolveValueComponent(field); - const innerType = (valueComponent as { type?: string })?.type; - const baseInputs: Record = { - key: field, - label: this.getFieldLabel(field), - value: this.tableRowFieldsShown[field], - structure: this.tableRowStructure[field], - relations: this.tableTypes[field] === 'foreign key' ? this.tableForeignKeys[field] : undefined, - autofocus: this.autofocusField === field, - }; - - if (innerType === 'text' || innerType === 'number' || innerType === 'datetime') { - return { - ...baseInputs, - valueComponent, - comparator: this.tableRowFieldsComparator[field] || 'eq', - }; - } - - if (this.isWidget(field)) { - return { ...baseInputs, widgetStructure: this.tableWidgets[field] }; - } - - return baseInputs; - } - - private _filter(value: string): { key: string; label: string }[] { - const v = value.toLowerCase(); - return this.fields.filter((field) => field.key.toLowerCase().includes(v) || field.label.toLowerCase().includes(v)); - } - - ngDoCheck() { - const change = this.differ.diff(this); - if (change) { - this.tableFiltersCount = Object.keys(this.tableRowFieldsShown).length; - } - } - - get inputs() { - return filterTypes[this._connections.currentConnection.type]; - } - - setWidgets(widgets: Widget[]) { - this.tableWidgets = Object.assign( - {}, - ...widgets.map((widget: Widget) => { - let params; - if (typeof widget.widget_params === 'string' && widget.widget_params !== '// No settings required') { - params = JSON5.parse(widget.widget_params); - } else if (typeof widget.widget_params !== 'string') { - params = widget.widget_params; - } else { - params = ''; - } - return { - [widget.field_name]: { ...widget, widget_params: params }, - }; - }), - ); - } - - isWidget(columnName: string) { - return this.tableWidgets && columnName in this.tableWidgets; - } - - updateField = (updatedValue: any, field: string) => { - this.tableRowFieldsShown[field] = updatedValue; - }; - - updateComparatorFromComponent = (comparator: string, field: string) => { - this.tableRowFieldsComparator[field] = comparator; - }; - - addFilter(e) { - const key = e.option.value; - this.tableRowFieldsShown = { ...this.tableRowFieldsShown, [key]: this.tableRowFields[key] }; - this.tableRowFieldsComparator = { - ...this.tableRowFieldsComparator, - [key]: this.tableRowFieldsComparator[key] || 'eq', - }; - this.fieldSearchControl.setValue(''); - } - - resetFilters() { - this.tableFilters = []; - this.tableRowFieldsShown = {}; - } - - getInputType(field: string) { - let widgetType; - if (this.isWidget(field)) { - widgetType = this.UIwidgets[this.tableWidgets[field].widget_type]?.type; - } else { - widgetType = this.inputs[this.tableTypes[field]]?.type; - } - return widgetType; - } - - getComparatorType(typeOfComponent) { - if (typeOfComponent === 'text') { - return 'text'; - } else if (typeOfComponent === 'number' || typeOfComponent === 'datetime') { - return 'number'; - } else { - return 'nonComparable'; - } - } - - removeFilter(field) { - delete this.tableRowFieldsShown[field]; - delete this.tableRowFieldsComparator[field]; - } - - private resolveValueComponent(field: string): Type { - if (this.isWidget(field)) { - const widgetType = this.tableWidgets[field].widget_type; - if (widgetType && this.UIwidgets[widgetType]) { - return this.UIwidgets[widgetType]; - } - } - return this.inputs[this.tableTypes[field]]; - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.css deleted file mode 100644 index e89768b33..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.css +++ /dev/null @@ -1,38 +0,0 @@ -.file-uploader-appearance { - display: flex; - align-items: center; - gap: 12px; -} - -.file-name { - color: rgba(0, 0, 0, 0.5); - font-style: italic; - margin-right: 20px; -} - -@media (prefers-color-scheme: dark) { - .file-name { - color: rgba(255, 255, 255, 0.5); - } -} - -.upload-btn { - margin-right: 12px; -} - -.form__warning { - margin-top: -12px; - margin-bottom: 20px; -} - -@media (prefers-color-scheme: light) { - .form__warning { - color: var(--color-warnPalette-500); - } -} - -@media (prefers-color-scheme: dark) { - .form__warning { - color: var(--color-warnDarkPalette-500); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.html deleted file mode 100644 index f9a415586..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

Import

-
- -

This is a TEST DATABASE, public to all. Avoid importing sensitive data!

-
- - {{ file && file.name ? file.name : 'No file selected.' }} - -
-
- - - - -
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.spec.ts deleted file mode 100644 index ebfa7760b..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Angulartics2Module } from 'angulartics2'; -import { DbTableImportDialogComponent } from './db-table-import-dialog.component'; - -describe('DbTableImportDialogComponent', () => { - let component: DbTableImportDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatDialogModule, - MatSnackBarModule, - FormsModule, - Angulartics2Module.forRoot(), - DbTableImportDialogComponent, - ], - providers: [ - provideHttpClient(), - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionID: '12345678', - tableName: 'users', - sortColumn: 'first_name', - sortOrder: 'asc', - filters: { first_name: { startswith: 'A' } }, - search: '', - }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DbTableImportDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.ts deleted file mode 100644 index 1567d1f68..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-import-dialog/db-table-import-dialog.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { TablesService } from 'src/app/services/tables.service'; - -@Component({ - selector: 'app-db-table-import-dialog', - templateUrl: './db-table-import-dialog.component.html', - styleUrls: ['./db-table-import-dialog.component.css'], - imports: [CommonModule, FormsModule, MatButtonModule, MatDialogModule], -}) -export class DbTableImportDialogComponent { - public file: File; - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _tables: TablesService, - public dialogRef: MatDialogRef, - private angulartics2: Angulartics2, - ) {} - - onFileSelected(event: any) { - this.file = event.target.files[0]; - } - - importCSV() { - this.submitting = true; - this._tables.importTableCSV(this.data.connectionID, this.data.tableName, this.file).subscribe( - (_res) => { - this.dialogRef.close(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Dashboard: db import is successful', - }); - posthog.capture('Dashboard: db import is successful'); - }, - (_err) => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.css deleted file mode 100644 index a8a412789..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.css +++ /dev/null @@ -1,270 +0,0 @@ -.row-preview-sidebar { - opacity: 0; - transform: translateX(100%); - min-height: 100%; - max-height: calc(100vh - 60px - 56px); - width: 0; - overflow-y: auto; - transition: - width 400ms ease, - transform 400ms ease, - opacity 400ms ease; -} - -.row-preview-sidebar_open { - position: relative; - background-color: var(--mat-sidenav-content-background-color); - border-left: solid 1px rgba(0, 0, 0, 0.12); - opacity: 1; - transform: translateX(0); - width: clamp(200px, 22vw, 400px); -} - -@media (prefers-color-scheme: dark) { - .row-preview-sidebar_open { - border-left: 1px solid var(--mat-sidenav-container-divider-color); - background-color: var(--surface-dark-color); - } -} - -@media (width <= 600px) { - .row-preview-sidebar_open { - position: fixed; - top: auto; - bottom: 0; - left: 0; - right: 0; - min-height: initial; - max-height: calc(80vh + env(safe-area-inset-bottom, 0px)); - width: 100vw; - z-index: 1001; - border-top-left-radius: 16px; - border-top-right-radius: 16px; - border-left: none !important; - box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18); - padding-bottom: env(safe-area-inset-bottom, 0px); - animation: rowPreviewSlideUp 240ms cubic-bezier(0.2, 0.8, 0.2, 1); - } -} - -.row-preview-backdrop { - display: none; -} - -.row-preview-sidebar__handle { - display: none; -} - -@media (width <= 600px) { - .row-preview-backdrop { - display: block; - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - z-index: 1000; - animation: rowPreviewBackdropFadeIn 240ms ease-out; - } - - .row-preview-sidebar__handle { - display: block; - width: 36px; - height: 4px; - border-radius: 2px; - background: rgba(0, 0, 0, 0.2); - margin: 8px auto 0; - } -} - -@media (prefers-color-scheme: dark) { - .row-preview-sidebar__handle { - background: rgba(255, 255, 255, 0.24); - } -} - -@keyframes rowPreviewSlideUp { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -@keyframes rowPreviewBackdropFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.row-preview-sidebar__header { - position: sticky; - top: 0; - background-color: inherit; - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 20px; - padding-bottom: 20px; - padding-left: 20px; - padding-right: 12px; - z-index: 1; -} - -.row-preview-sidebar__title { - margin-bottom: 0 !important; -} - -.row-preview-sidebar__actions { - display: flex; - gap: 4px; - margin-right: auto; - margin-left: 12px; -} - -.row-preview-sidebar__field { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - padding: 12px 20px; -} - -.row-preview-sidebar__field:not(:last-child)::after { - content: ''; - position: absolute; - left: 20px; - right: 20px; - bottom: 0; - height: 1px; - background: rgba(0, 0, 0, 0.12); -} - -@media (prefers-color-scheme: dark) { - .row-preview-sidebar__field:not(:last-child)::after { - background: rgba(255, 255, 255, 0.04); - } -} - -.row-preview-sidebar__value:has(app-json-editor-record-view), -.row-preview-sidebar__value:has(app-code-record-view) { - width: 100%; -} - -.related-records-section { - margin: 8px 0 0; - padding: 0 20px; -} - -.related-records-section__title { - margin: 0 0 4px !important; - padding: 0; - font-size: 12px !important; - font-weight: 600 !important; - line-height: 1.4 !important; - text-transform: uppercase; - letter-spacing: 0.06em; - opacity: 0.6; -} - -.related-records__header { - padding: 0 12px 0 8px; -} - -.related-records__table-name { - flex: 1 1 auto; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - margin-right: 8px; -} - -.related-records__table-name-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.related-records__actions { - flex: 0 0 auto; - justify-content: flex-end; - /* Stay clickable even when the panel is disabled (no related records). */ - pointer-events: auto; -} - -/* Keep the action icons fully dark in every panel state (enabled or - disabled), instead of Material's lighter default icon color. */ -.related-records__actions .mat-icon { - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .related-records__actions .mat-icon { - color: rgba(255, 255, 255, 0.87); - } -} - -.related-records__empty-mark { - flex: 0 0 auto; - font-size: 11px; - line-height: 16px; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 1px 6px; - border-radius: 8px; - color: rgba(0, 0, 0, 0.5); - background: rgba(0, 0, 0, 0.06); -} - -.related-records__add-button { - flex: 0 0 auto; - white-space: nowrap; -} - -/* Keep header content legible when the panel is disabled (no related - records) — disabling only blocks expand/collapse, not the actions. */ -.related-records__panel ::ng-deep .mat-expansion-panel-header[aria-disabled='true'] { - color: inherit; - cursor: default; -} - -@media (prefers-color-scheme: dark) { - .related-records__empty-mark { - color: rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.1); - } -} - -.related-record { - --mat-list-list-item-two-line-container-height: 60px; - - padding-left: 8px; - padding-right: 8px; -} - -.related-record__open { - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .related-record__open { - color: rgba(255, 255, 255, 0.7); - } -} - -.related-record ::ng-deep .mdc-list-item__primary-text::before { - height: 24px; -} - -.related-records__panel ::ng-deep .mat-expansion-panel-body { - padding: 0 8px; -} -.related-record ::ng-deep .mdc-list-item__end { - margin-right: -8px !important; - margin-left: 4px !important; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.html deleted file mode 100644 index f765fdc64..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.html +++ /dev/null @@ -1,145 +0,0 @@ -
-
- -
-

Preview

-
- - - create - -
- -
- - - - -
- {{column.normalizedTitle}} -
- - - - - - - - - - - - - -
-
-
- - - - -
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts deleted file mode 100644 index 70f8737fc..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { ActivatedRoute } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { DbTableRowViewComponent } from './db-table-row-view.component'; - -describe('DbTableRowViewComponent', () => { - let component: DbTableRowViewComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, DbTableRowViewComponent, Angulartics2Module.forRoot()], - providers: [provideHttpClient(), { provide: ActivatedRoute, useValue: {} }], - }); - fixture = TestBed.createComponent(DbTableRowViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('getForeignKeyValue', () => { - const baseRow = { - foreignKeys: { - user_id: { - column_name: 'user_id', - constraint_name: 'fk_user', - referenced_column_name: 'id', - referenced_table_name: 'users', - }, - }, - }; - - it('returns null when record field is null', () => { - component.selectedRow = { ...baseRow, record: { user_id: null } } as any; - expect(component.getForeignKeyValue('user_id')).toBeNull(); - }); - - it('returns null when record field is undefined', () => { - component.selectedRow = { ...baseRow, record: {} } as any; - expect(component.getForeignKeyValue('user_id')).toBeNull(); - }); - - it('returns identity column value when FK object has one', () => { - component.selectedRow = { - ...baseRow, - record: { user_id: { id: 42, name: 'alice' } }, - } as any; - expect(component.getForeignKeyValue('user_id')).toBe('alice'); - }); - - it('returns primitive FK value as-is (including 0)', () => { - component.selectedRow = { ...baseRow, record: { user_id: 0 } } as any; - expect(component.getForeignKeyValue('user_id')).toBe(0); - }); - - it('returns primitive FK value as-is (including empty string)', () => { - component.selectedRow = { ...baseRow, record: { user_id: '' } } as any; - expect(component.getForeignKeyValue('user_id')).toBe(''); - }); - }); - - describe('getForeignKeyQueryParams', () => { - const baseRow = { - foreignKeys: { - user_id: { - column_name: 'user_id', - constraint_name: 'fk_user', - referenced_column_name: 'id', - referenced_table_name: 'users', - }, - }, - }; - - it('returns {} when record field is null', () => { - component.selectedRow = { ...baseRow, record: { user_id: null } } as any; - expect(component.getForeignKeyQueryParams('user_id')).toEqual({}); - }); - - it('returns referenced column param when FK is an object', () => { - component.selectedRow = { - ...baseRow, - record: { user_id: { id: 42, name: 'alice' } }, - } as any; - expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 42 }); - }); - - it('returns referenced column param when FK is a primitive', () => { - component.selectedRow = { ...baseRow, record: { user_id: 7 } } as any; - expect(component.getForeignKeyQueryParams('user_id')).toEqual({ id: 7 }); - }); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts deleted file mode 100644 index 13997e992..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-row-view/db-table-row-view.component.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, RouterModule } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import { ForeignKeyRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component'; -import { recordViewFieldTypes, UIwidgets } from 'src/app/consts/record-view-types'; -import { formatFieldValue } from 'src/app/lib/format-field-value'; -import { normalizeFieldName, normalizeTableName } from 'src/app/lib/normalize'; -import { TableField, TableRow, Widget } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { PlaceholderRecordViewComponent } from '../../../skeletons/placeholder-record-view/placeholder-record-view.component'; - -@Component({ - selector: 'app-db-table-row-view', - templateUrl: './db-table-row-view.component.html', - styleUrls: ['./db-table-row-view.component.css'], - imports: [ - MatIconModule, - MatButtonModule, - ClipboardModule, - MatTooltipModule, - MatExpansionModule, - MatProgressSpinnerModule, - MatListModule, - RouterModule, - CommonModule, - PlaceholderRecordViewComponent, - ForeignKeyRecordViewComponent, - DynamicModule, - SignalComponentIoModule, - ], -}) -export class DbTableRowViewComponent implements OnInit, OnDestroy { - @Input() activeFilters: object; - - public selectedRowCast: any; - public selectedRow: TableRow; - public columns: { - title: string; - normalizedTitle: string; - }[] = []; - public referencedTables: { table_name: string; displayTableName: string; columns: string[] }[] = []; - public referencedTablesURLParams: any; - public referencedRecords: {} = {}; - public recordViewComponents; - public widgetsMap: { [key: string]: any } = {}; - public UIwidgets = UIwidgets; - - constructor( - private _tables: TablesService, - private _tableState: TableStateService, - private _notifications: NotificationsService, - private _connections: ConnectionsService, - private route: ActivatedRoute, - ) {} - - ngOnInit(): void { - // this.connectionID = this._connections.connectionID; - - const connectionType = this._connections.currentConnection.type; - this.recordViewComponents = recordViewFieldTypes[connectionType]; - - this.selectedRowCast = this._tableState.cast.subscribe((row) => { - this.selectedRow = row; - - console.log('Selected row:', this.selectedRow); - - if (row?.columnsOrder) { - const columnsOrder = this.selectedRow.columnsOrder.length - ? this.selectedRow.columnsOrder - : Object.keys(this.selectedRow.record); - - this.columns = columnsOrder.map((column) => { - return { - title: column, - normalizedTitle: row.widgets[column]?.name || normalizeFieldName(column), - }; - }); - - if (row.relatedRecords?.referenced_by?.length) { - this.referencedRecords = {}; - - this.referencedTables = row.relatedRecords.referenced_by.map((table: any) => { - return { ...table, displayTableName: table.display_name || normalizeTableName(table.table_name) }; - }); - - this.referencedTablesURLParams = row.relatedRecords.referenced_by.map((table: any) => { - const params = { - [table.column_name]: { - eq: row.record[row.relatedRecords.referenced_on_column_name], - }, - }; - return { - filters: JsonURL.stringify(params), - page_index: 0, - }; - }); - - row.relatedRecords.referenced_by.forEach((table: any) => { - const filters = { - [table.column_name]: { - eq: row.record[row.relatedRecords.referenced_on_column_name], - }, - }; - - this._tables - .fetchTable({ - connectionID: row.connectionID, - tableName: table.table_name, - requstedPage: 1, - chunkSize: 30, - filters, - }) - .subscribe((res) => { - let identityColumn = res.identity_column; - let fieldsOrder = []; - - const foreignKeyMap = {}; - for (const fk of res.foreignKeys) { - foreignKeyMap[fk.column_name] = fk.referenced_column_name; - } - - const tableWidgetsNameMap = res.widgets.reduce((acc, widget: Widget) => { - acc[widget.field_name] = widget.name; - return acc; - }, {}); - - // Format each row - const formattedRows = res.rows.map((row) => { - const formattedRow = {}; - - for (const key in row) { - if (foreignKeyMap[key] && typeof row[key] === 'object' && row[key] !== null) { - const preferredKey = Object.keys(row[key]).find((k) => k !== foreignKeyMap[key]); - formattedRow[key] = preferredKey ? row[key][preferredKey] : row[key][foreignKeyMap[key]]; - } else { - formattedRow[key] = formatFieldValue( - row[key], - res.structure.find((field: TableField) => field.column_name === key)?.data_type || 'text', - ); - } - } - return formattedRow; - }); - - if (res.identity_column && res.list_fields.length) { - identityColumn = { - isSet: true, - fieldName: res.identity_column, - displayName: tableWidgetsNameMap[res.identity_column] || normalizeFieldName(res.identity_column), - }; - fieldsOrder = res.list_fields - .filter((field: string) => field !== res.identity_column) - .slice(0, 3) - .map((field: string) => { - return { - fieldName: field, - displayName: tableWidgetsNameMap[field] || normalizeFieldName(field), - }; - }); - } - - if (res.identity_column && !res.list_fields.length) { - identityColumn = { - isSet: true, - fieldName: res.identity_column, - displayName: tableWidgetsNameMap[res.identity_column] || normalizeFieldName(res.identity_column), - }; - fieldsOrder = res.structure - .filter((field: TableField) => field.column_name !== res.identity_column) - .slice(0, 3) - .map((field: TableField) => { - return { - fieldName: field.column_name, - displayName: tableWidgetsNameMap[field.column_name] || normalizeFieldName(field.column_name), - }; - }); - } - - if (!res.identity_column && res.list_fields.length) { - identityColumn = { - isSet: false, - fieldName: res.list_fields[0], - displayName: tableWidgetsNameMap[res.list_fields[0]] || normalizeFieldName(res.list_fields[0]), - }; - fieldsOrder = res.list_fields.slice(1, 4).map((field: string) => { - return { - fieldName: field, - displayName: tableWidgetsNameMap[field] || normalizeFieldName(field), - }; - }); - } - - if (!res.identity_column && !res.list_fields.length) { - identityColumn = { - isSet: false, - fieldName: res.structure[0].column_name, - displayName: - tableWidgetsNameMap[res.structure[0].column_name] || - normalizeFieldName(res.structure[0].column_name), - }; - console.log(identityColumn); - fieldsOrder = res.structure.slice(1, 4).map((field: TableField) => { - return { - fieldName: field.column_name, - displayName: tableWidgetsNameMap[field.column_name] || normalizeFieldName(field.column_name), - }; - }); - } - - const tableRecords = { - rows: formattedRows, - links: res.rows.map((row) => { - return res.primaryColumns.reduce( - (keys, column) => { - if (res.foreignKeys.map((foreignKey) => foreignKey.column_name).includes(column.column_name)) { - const referencedColumnNameOfForeignKey = foreignKeyMap[column.column_name]; - keys[column.column_name] = row[column.column_name][referencedColumnNameOfForeignKey]; - } else { - keys[column.column_name] = row[column.column_name]; - } - return keys; - }, - { mode: 'view' }, - ); - }), - identityColumn, - fieldsOrder, - }; - - this.referencedRecords[table.table_name] = tableRecords; - }); - }); - } - } - }); - } - - ngOnDestroy() { - this.selectedRowCast.unsubscribe(); - } - - isForeignKey(columnName: string) { - return this.selectedRow.foreignKeysList.includes(columnName); - } - - getForeignKeyValue(field: string) { - const cell = this.selectedRow?.record?.[field]; - if (cell == null) return null; - if (typeof cell === 'object') { - const referencedColumnName = this.selectedRow.foreignKeys[field].referenced_column_name; - const identityColumnName = Object.keys(cell).find((key) => key !== referencedColumnName); - if (identityColumnName) return cell[identityColumnName]; - if (referencedColumnName && cell[referencedColumnName] != null) return cell[referencedColumnName]; - return null; - } - return cell; - } - - getForeignKeyQueryParams(field: string) { - const cell = this.selectedRow?.record?.[field]; - const referencedColumnName = this.selectedRow?.foreignKeys?.[field]?.referenced_column_name; - if (cell == null || !referencedColumnName) return {}; - return { [referencedColumnName]: typeof cell === 'object' ? cell[referencedColumnName] : cell }; - } - - isWidget(columnName: string) { - return this.selectedRow.widgetsList.includes(columnName); - } - - getDedicatedPageLinkParams() { - if (this.selectedRow) { - const params = {}; - for (const key in this.selectedRow.primaryKeys) { - if (Object.hasOwn(this.selectedRow.primaryKeys, key)) { - if (this.selectedRow.foreignKeysList.includes(key)) { - const referencedColumnName = this.selectedRow.foreignKeys[key].referenced_column_name; - params[key] = this.selectedRow.record[key][referencedColumnName]; - } else { - params[key] = this.selectedRow.primaryKeys[key]; - } - } - } - return params; - } - return {}; - } - - getDedicatedPageLink() { - if (this.selectedRow) { - const paramsObj = this.getDedicatedPageLinkParams(); - const params = new URLSearchParams(); - for (const key in paramsObj) { - if (Object.hasOwn(paramsObj, key)) { - params.set(key, paramsObj[key]); - } - } - return `${location.origin}${this.selectedRow.link}?${params.toString()}`; - } - return ''; - } - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message, 'top'); - } - - stashUrlParams() { - this._tableState.setBackUrlParams( - this.route.snapshot.queryParams.page_index, - this.route.snapshot.queryParams.page_size, - this.route.snapshot.queryParams.sort_active, - this.route.snapshot.queryParams.sort_direction, - ); - if (this.activeFilters && Object.keys(this.activeFilters).length > 0) { - this._tableState.setBackUrlFilters(this.activeFilters); - } else { - this._tableState.setBackUrlFilters(null); - } - - this._tableState.clearSelection(); - } - - handleClose() { - this._tableState.clearSelection(); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.css deleted file mode 100644 index 20082639f..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.css +++ /dev/null @@ -1,232 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.settings-page { - display: flex; - flex-direction: column; - align-items: center; - margin: 2.5em 0 0; -} - -.settings-page .mat-mdc-form-field-bottom-align::before { - margin-left: 0; -} - -.settings-form { - width: 100%; -} - -.settings-fields { - display: flex; - flex-direction: column; - gap: 20px; - padding: 24px max(calc(50vw - 325px), 10%) 60px; -} - -@media (width <= 600px) { - .settings-fields { - gap: 24px; - padding: 0 9vw 72px; - } -} - -.settings-fields__subheading { - margin-top: -4px !important; - margin-bottom: 0 !important; -} - -@media (prefers-color-scheme: dark) { - .settings-fields__subheading { - color: rgba(255, 255, 255, 0.64); - } -} - -@media (prefers-color-scheme: light) { - .settings-fields__subheading { - color: rgba(0, 0, 0, 0.64); - } -} - -.settings-table-display { - display: flex; - align-items: center; - gap: 12px; - width: 100%; -} - -.settings-table-display__title { - flex-grow: 1; -} - -.settings-fields__heading { - margin-top: 20px !important; - margin-bottom: -4px !important; -} - -.icon-picker { - margin-left: -4px; - margin-bottom: 20px; -} - -.order-settings { - display: flex; - gap: 12px; -} - -.order-settings__panel { - flex-grow: 1; - position: relative; - margin-top: 16px; - margin-bottom: 8px; -} - -.order-settings__expansion-panel { - position: relative; - box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.15); -} - -.order-settings ::ng-deep .mat-expansion-panel-header { - height: auto; - min-height: 44px; - padding: 0.75em; -} - -.order-settings__reset-button { - margin-top: 4px; -} - -.mat-expansion-panel:not(.mat-expanded):hover { - box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.87); -} - -.mat-expansion-panel:not(.mat-expanded) .mat-expansion-panel-header:hover:not([aria-disabled="true"]) { - background: transparent; -} - -.mat-expansion-panel-header { - position: relative; - height: 60px; - padding: 0 0.75em; -} - -.order__title { - position: absolute; - top: -12px; - left: 8px; - display: inline-block; - font-size: 0.75em; - padding: 4px; - z-index: 1; -} - -@media (prefers-color-scheme: dark) { - .order__title { - background: transparent; - color: rgba(255, 255, 255, 0.6); - } -} - -@media (prefers-color-scheme: light) { - .order__title { - background: #fff; - color: rgba(0, 0, 0, 0.6); - } -} - -.order-item { - background: var(--mat-expansion-container-background-color); - color: var(--mat-expansion-container-text-color); - box-sizing: border-box; - cursor: move; - padding: 20px 12px; -} - -.order-item:not(:last-child) { - border-bottom: solid 1px var(--mat-table-row-item-outline-color); -} - -.cdk-drag-preview { - box-sizing: border-box; - border-radius: 4px; - box-shadow: - 0 5px 5px -3px rgba(0, 0, 0, 0.2), - 0 8px 10px 1px rgba(0, 0, 0, 0.14), - 0 3px 14px 2px rgba(0, 0, 0, 0.12); -} - -.settings-form__tooltip { - margin-bottom: 20px; -} - -.form-group-ordering { - display: flex; - align-items: center; - gap: 12px; -} - -@media (width <= 600px) { - .form-group-ordering { - flex-direction: column; - align-items: flex-start; - } -} - -.form-group-ordering__ordering-by { - width: 50%; -} - -@media (width <= 600px) { - .form-group-ordering__ordering-by { - width: 100%; - } -} - -.form-group-ordering__order-options { - flex-shrink: 0; - display: flex; - gap: 12px; - margin-top: -20px; -} - -.mat-form-field_excludedFields { - margin-top: 16px; - margin-bottom: 20px; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--background-color); - box-shadow: var(--shadow); - height: 64px; - padding: 0 max(calc(50vw - 325px), 10%); - width: 100vw; - z-index: 2; -} - -@media (prefers-color-scheme: dark) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.5), 0 2px 2px 0 rgba(0, 0, 0, 0.64), 0 1px 5px 0 rgba(0, 0, 0, 0.85); - --background-color: var(--surface-dark-color); - } -} - -@media (prefers-color-scheme: light) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); - --background-color: #fff; - } -} - -.settings-form__back-button { - margin-right: auto; -} - -.settings-form__save-button { - margin-left: 20px; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.html deleted file mode 100644 index 6e3f7e20e..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.html +++ /dev/null @@ -1,198 +0,0 @@ - - - -
- -
-
-

General settings

- -
- - Displayed table name - - - Change the name of your table. This will not override your database. - - - -
- - - Sensitive fields - - {{field}} - - Choose the fields you want hidden in the "Audit" tab. They won't be logged on our servers. - - - - Identity field - - None - {{field}} - - Select a column you want to display as a foreign key field. - - - - Allow importing rows from CSV - - - - Allow table export to CSV - - - - Allow row deletion - - -

Table view

- - Columns visible by default - - {{field}} - - Choose the columns you want users to see when they first open this table. Changes made to a table's Columns view are saved over the default values. - - -
-
- - Columns order - - - - -
- Standard order - - - {{field}}{{ i === tableSettings.list_fields.length - 1 ? '' : ',' }} - - -
-
-
- -
-
{{field}}
-
-
-
- -
- - - Searchable columns - - {{field}} - - Choose the columns Rocketadmin scans when using the Search bar. - - -
- - Order by - - None - - {{field}} - - - - - Ascending - Descending - -
- - - Sortable columns - - {{field}} - - Choose which columns allow you to sort their values by pressing the arrow next to the column's name. Columns which aren't selected will not be sortable. - - - - Columns to hide - - {{field}} - - Choose the columns you want to hide from the "Table" view and the "Edit row" view. This also removes the option to edit them. - - -

"Edit row" behavior

-

You can edit a row by clicking on the "pencil" icon on the right.

- - Searchable foreign key columns - - {{field}} - - Choose the columns Rocketadmin scans within a related table when selecting a foreign key. - - - - Read-only fields - - {{field}} - - Choose columns that will be visible in "Table" view but not editable in "Edit row" view. - -
- -
- - - - -
-
- -
\ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.spec.ts deleted file mode 100644 index 2d5f6482e..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NgForm } from '@angular/forms'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { TableOrdering, TableSettings } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DbTableSettingsComponent } from './db-table-settings.component'; - -describe('DbTableSettingsComponent', () => { - let component: DbTableSettingsComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let connectionsService: ConnectionsService; - - const fakeFirstName = { - column_name: 'FirstName', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }; - const fakeId = { - column_name: 'Id', - column_default: 'auto_increment', - data_type: 'int', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }; - const fakeBool = { - column_name: 'bool', - column_default: null, - data_type: 'tinyint', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const mockTableStructure = { - structure: [fakeFirstName, fakeId, fakeBool], - primaryColumns: [ - { - data_type: 'int', - column_name: 'Id', - }, - ], - foreignKeys: [ - { - referenced_column_name: 'CustomerId', - referenced_table_name: 'Customers', - constraint_name: 'Orders_ibfk_2', - column_name: 'Id', - }, - ], - readonly_fields: [], - table_widgets: [], - }; - - const mockTableSettings: TableSettings = { - // id: "f3df6ca8-18af-4347-9777-47c086d83969", - table_name: 'actor', - display_name: '', - icon: '', - search_fields: [], - excluded_fields: [], - list_fields: [], - ordering: TableOrdering.Ascending, - ordering_field: '', - columns_view: [], - // identification_fields: [], - // list_per_page: null, - identity_column: '', - readonly_fields: [], - sortable_by: [], - autocomplete_columns: ['FirstName'], - sensitive_fields: [], - connection_id: '63f804e4-8588-4957-8d7f-655e2309fef7', - allow_csv_export: true, - allow_csv_import: true, - can_delete: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - FormsModule, - MatSelectModule, - MatRadioModule, - MatInputModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - DbTableSettingsComponent, - ], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableSettingsComponent); - component = fixture.componentInstance; - tablesService = TestBed.inject(TablesService); - connectionsService = TestBed.inject(ConnectionsService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set initial state', () => { - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('12345678'); - vi.spyOn(tablesService, 'currentTableName', 'get').mockReturnValue('users'); - vi.spyOn(tablesService, 'fetchTableStructure').mockReturnValue(of(mockTableStructure)); - vi.spyOn(tablesService, 'fetchTableSettings').mockReturnValue(of(mockTableSettings)); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.fields).toEqual(['FirstName', 'Id', 'bool']); - expect(component.fields_to_exclude).toEqual(['FirstName', 'bool']); - expect(component.tableSettings).toEqual(mockTableSettings); - }); - - it('should update settings', () => { - const fakeUpdateTableSettings = vi.spyOn(tablesService, 'updateTableSettings').mockReturnValue(of()); - component.isSettingsExist = true; - component.connectionID = '12345678'; - component.tableName = 'users'; - component.tableSettings = mockTableSettings; - - component.updateSettings(); - - expect(fakeUpdateTableSettings).toHaveBeenCalledWith(true, '12345678', 'users', mockTableSettings); - }); - - it('should delete settings', () => { - // const fakeUpdateTableSettings = vi.spyOn(tablesService, 'updateTableSettings').mockReturnValue(of()); - // component.isSettingsExist = true; - component.connectionID = '12345678'; - component.tableName = 'users'; - // component.tableSettings = mockTableSettings; - const fakeDeleteSettings = vi.spyOn(tablesService, 'deleteTableSettings').mockReturnValue(of()); - - const testForm = { - value: { - name: 'tableSettingsForm', - }, - }; - - component.resetSettings(testForm); - - expect(fakeDeleteSettings).toHaveBeenCalledWith('12345678', 'users'); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.ts deleted file mode 100644 index d27c013d9..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-settings/db-table-settings.component.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; -import { CommonModule, Location } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule, NgForm } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { Title } from '@angular/platform-browser'; -import { Router, RouterModule } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TableField, TableOrdering, TableSettings } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { PlaceholderTableSettingsComponent } from '../../../skeletons/placeholder-table-settings/placeholder-table-settings.component'; -import { AlertComponent } from '../../../ui-components/alert/alert.component'; -import { BreadcrumbsComponent } from '../../../ui-components/breadcrumbs/breadcrumbs.component'; -import { IconPickerComponent } from '../../../ui-components/icon-picker/icon-picker.component'; - -@Component({ - selector: 'app-db-table-settings', - templateUrl: './db-table-settings.component.html', - styleUrls: ['./db-table-settings.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - DragDropModule, - MatButtonModule, - MatSlideToggleModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatIconModule, - MatExpansionModule, - MatRadioModule, - AlertComponent, - BreadcrumbsComponent, - IconPickerComponent, - PlaceholderTableSettingsComponent, - ], -}) -export class DbTableSettingsComponent implements OnInit { - public connectionID: string | null = null; - public tableName: string | null = null; - public displayTableName: string | null = null; - public submitting: boolean = false; - public isSettingsExist: boolean = false; - public loading: boolean = true; - public fields: string[]; - public fields_to_exclude: string[]; - public orderChanged: boolean = false; - public iconChanged: boolean = false; - public listFieldsOrder: string[]; - public tableSettingsInitial: TableSettings = { - connection_id: '', - table_name: '', - icon: '', - display_name: '', - autocomplete_columns: [], - identity_column: '', - ordering: TableOrdering.Ascending, - ordering_field: '', - list_fields: [], - columns_view: [], - search_fields: [], - excluded_fields: [], - readonly_fields: [], - sortable_by: [], - sensitive_fields: [], - allow_csv_export: true, - allow_csv_import: true, - can_delete: true, - }; - public tableSettings: TableSettings = null; - public defaultIcons = [ - 'favorite', - 'star', - 'done', - 'arrow_forward', - 'key', - 'lock', - 'visibility', - 'language', - 'notifications', - 'schedule', - ]; - - constructor( - private _tables: TablesService, - private _connections: ConnectionsService, - private _location: Location, - public router: Router, - private title: Title, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this.tableName = this._tables.currentTableName; - this.displayTableName = normalizeTableName(this.tableName); - this._tables.cast.subscribe(); - this._tables.fetchTableStructure(this.connectionID, this.tableName).subscribe((res) => { - const primaryKeys = res.primaryColumns.map((primaryColumn) => primaryColumn.column_name); - this.fields = res.structure.map((field: TableField) => field.column_name); - this.fields_to_exclude = this.fields.filter((field) => !primaryKeys.includes(field)); - this.getTableSettings(); - }); - } - - get currentConnection() { - return this._connections.currentConnection; - } - - getCrumbs(name: string) { - return [ - { - label: name, - link: `/dashboard/${this.connectionID}`, - }, - { - label: this.tableSettings.display_name || this.displayTableName, - link: `/dashboard/${this.connectionID}/${this.tableName}`, - }, - { - label: 'Settings', - link: null, - }, - ]; - } - - goBack() { - this._location.back(); - } - - getTableSettings() { - this._tables.fetchTableSettings(this.connectionID, this.tableName).subscribe((res) => { - this.loading = false; - if (Object.keys(res).length !== 0) { - this.isSettingsExist = true; - this.tableSettings = res; - this.listFieldsOrder = [...res.list_fields]; - } else { - this.tableSettings = this.tableSettingsInitial; - } - if (Object.keys(res).length === 0 || (res && res.list_fields && !res.list_fields.length)) { - this.listFieldsOrder = [...this.fields]; - } - this.title.setTitle(`${res.display_name || this.displayTableName} - Table settings | Rocketadmin`); - }); - } - - updateIcon(icon: string) { - this.tableSettings.icon = icon; - this.iconChanged = true; - } - - drop(event: CdkDragDrop) { - moveItemInArray(this.listFieldsOrder, event.previousIndex, event.currentIndex); - this.tableSettings.list_fields = [...this.listFieldsOrder]; - this.orderChanged = true; - } - - resetColumnsOrder() { - this.tableSettings.list_fields = []; - this.listFieldsOrder = [...this.fields]; - this.orderChanged = true; - } - - updateSettings() { - this.submitting = true; - this.tableSettings.connection_id = this.connectionID; - this.tableSettings.table_name = this.tableName; - - const updatedSettings = {}; - - for (const [key, value] of Object.entries(this.tableSettings)) { - if (key !== 'connection_id' && key !== 'table_name' && key !== 'ordering') { - if (Array.isArray(value)) { - if (key === 'list_fields') { - updatedSettings[key] = this.orderChanged; - } else { - updatedSettings[key] = value.length > 0; - } - } else { - updatedSettings[key] = Boolean(value); - } - } - } - - this._tables - .updateTableSettings(this.isSettingsExist, this.connectionID, this.tableName, this.tableSettings) - .subscribe( - () => { - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Table settings: updated successfully', - properties: updatedSettings, - }); - posthog.capture('Table settings: updated successfully'); - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}`]); - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - resetSettings(form: NgForm) { - this.submitting = true; - this._tables.deleteTableSettings(this.connectionID, this.tableName).subscribe( - () => { - form.reset(); - this.submitting = false; - this.tableSettings = { ...this.tableSettingsInitial }; - this.angulartics2.eventTrack.next({ - action: 'Table settings: reset successfully', - }); - posthog.capture('Table settings: reset successfully'); - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.css deleted file mode 100644 index 0b642e84e..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.css +++ /dev/null @@ -1,1877 +0,0 @@ -.hidden { - display: none; -} - -.pull-refresh-indicator { - display: none; -} - -@media (width <= 600px) { - .pull-refresh-indicator { - display: flex; - align-items: center; - justify-content: center; - height: 0; - overflow: hidden; - transition: height 150ms ease; - } - - .pull-refresh-indicator__icon { - opacity: 0.6; - transition: transform 100ms linear; - } - - .pull-refresh-indicator__icon_spinning { - animation: pullRefreshSpin 700ms linear infinite; - } - - .refresh-button { - display: none !important; - } -} - -@keyframes pullRefreshSpin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.db-table-header { - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - margin-top: 16px; - width: 100%; -} - -@media (width <= 600px) { - .db-table-header { - flex-direction: column; - margin-bottom: 16px; - } -} - -.db-table-title { - flex-grow: 50; - display: flex; - align-items: center; - margin-right: auto; - z-index: 1; -} - -@media (width <= 600px) { - .db-table-title { - flex-grow: 1; - margin-right: 0; - margin-bottom: 12px; - justify-content: space-between; - } -} - -.table-name { - margin-bottom: 0 !important; - margin-right: 8px !important; -} - -@media (width <= 600px) { - .table-name { - display: none; - } -} - -.table-title__settings, -.table-title__ai { - display: none; -} - -.settings-menu__transfer { - display: none !important; -} - -@media (width <= 600px) { - .table-title__ai { - display: inline-flex; - margin-left: auto; - } - - .table-title__settings { - display: inline-flex; - } - - .actions__settings, - .actions__transfer, - .ai-insights-button { - display: none !important; - } - - button.settings-menu__transfer { - display: flex !important; - } - - mat-divider.settings-menu__transfer { - display: block !important; - } -} - -.table-switcher { - display: none; -} - -@media (width <= 600px) { - .table-switcher { - display: inline-flex; - align-items: center; - padding: 0 4px 0 8px; - line-height: 1.2; - min-height: 40px; - --mdc-text-button-label-text-size: 20px; - --mdc-text-button-label-text-weight: 500; - --mat-text-button-label-text-size: 20px; - --mat-text-button-label-text-weight: 500; - } - - .table-switcher ::ng-deep .mdc-button__label { - font-size: 20px; - font-weight: 500; - } - - .table-switcher__name { - max-width: 60vw; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 20px; - font-weight: 500; - } - - .table-switcher__chevron { - margin-left: 0 !important; - font-size: 24px; - width: 24px; - height: 24px; - } -} - -.table-sheet-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(2px); - z-index: 1000; - transform: translateZ(0); - animation: tableSheetFadeIn 200ms ease-out; -} - -.table-sheet { - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 1001; - display: flex; - flex-direction: column; - max-height: 80vh; - background: var(--mat-sidenav-content-background-color, #fff); - border-top-left-radius: 16px; - border-top-right-radius: 16px; - box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18); - padding-bottom: env(safe-area-inset-bottom, 0px); - transform: translateZ(0); - animation: tableSheetSlideUp 260ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -.table-sheet__handle { - width: 36px; - height: 4px; - border-radius: 2px; - background: rgba(0, 0, 0, 0.2); - margin: 8px auto 0; - flex: 0 0 auto; -} - -.table-sheet__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 8px 8px 16px; - flex: 0 0 auto; -} - -.table-sheet__title { - font-size: 16px; - font-weight: 600; -} - -.table-sheet__search { - display: flex; - align-items: center; - gap: 8px; - margin: 0 16px 12px; - padding: 8px 12px; - border-radius: 999px; - background: rgba(0, 0, 0, 0.05); - flex: 0 0 auto; -} - -.table-sheet__search-icon { - font-size: 20px; - width: 20px; - height: 20px; - opacity: 0.55; - flex: 0 0 auto; -} - -.table-sheet__search-input { - flex: 1 1 auto; - min-width: 0; - background: transparent; - border: none; - outline: none; - font-size: 15px; - padding: 2px 0; - color: rgba(0, 0, 0, 0.87); -} - -.table-sheet__search-input::placeholder { - color: rgba(0, 0, 0, 0.45); -} - -.table-sheet__search-clear { - font-size: 18px; - width: 18px; - height: 18px; - opacity: 0.5; - cursor: pointer; - flex: 0 0 auto; -} - -.table-sheet__list { - overflow-y: auto; - padding: 0 8px 12px; - flex: 1 1 auto; - -webkit-overflow-scrolling: touch; -} - -.table-sheet__group-label { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 10px 12px; - margin-top: 4px; - border: none; - background: transparent; - font-size: 13px; - font-weight: 600; - color: inherit; - cursor: pointer; - text-align: left; -} - -.table-sheet__group-icon { - font-size: 18px; - width: 18px; - height: 18px; - flex: 0 0 auto; -} - -.table-sheet__group-name { - flex: 0 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.table-sheet__group-count { - font-size: 12px; - font-weight: 500; - opacity: 0.5; -} - -.table-sheet__group-chevron { - margin-left: auto; - font-size: 20px; - width: 20px; - height: 20px; - opacity: 0.55; - flex: 0 0 auto; -} - -.table-sheet__item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - border: none; - background: transparent; - text-align: left; - padding: 12px; - border-radius: 8px; - font-size: 15px; - color: inherit; - cursor: pointer; -} - -.table-sheet__item:active { - background: rgba(0, 0, 0, 0.06); -} - -.table-sheet__item_active { - font-weight: 600; - background: rgba(0, 0, 0, 0.04); -} - -.table-sheet__item-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-right: auto; -} - -.table-sheet__item-icon { - font-size: 22px; - width: 22px; - height: 22px; - flex: 0 0 auto; - margin-right: 12px; - opacity: 0.7; -} - -.table-sheet__item-check { - font-size: 20px; - width: 20px; - height: 20px; - flex: 0 0 auto; - color: var(--color-accentedPalette-600); -} - -.table-sheet__empty { - padding: 24px 16px; - font-size: 14px; - opacity: 0.6; - text-align: center; -} - -.sort-sheet__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - padding: 12px; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; -} - -.sort-sheet__row_expanded { - background: rgba(0, 0, 0, 0.04); -} - -.sort-sheet__col-name { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 15px; -} - -.sort-sheet__row-indicator { - flex: 0 0 auto; - font-size: 20px; - width: 20px; - height: 20px; - opacity: 0.5; -} - -.sort-sheet__options { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px 8px 10px 8px; -} - -.sort-sheet__opt { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - border: none; - background: transparent; - text-align: left; - padding: 10px 12px; - border-radius: 8px; - font-size: 14px; - color: inherit; - cursor: pointer; - opacity: 0.85; -} - -.sort-sheet__opt .mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - flex: 0 0 auto; - opacity: 0.7; -} - -.sort-sheet__opt:active { - background: rgba(0, 0, 0, 0.06); -} - -.sort-sheet__opt:disabled { - opacity: 0.35; - cursor: default; -} - -.sort-sheet__opt_active { - opacity: 1; - font-weight: 600; - color: var(--color-accentedPalette-700); - background: var(--color-accentedPalette-50); -} - -.sort-sheet__opt_active .mat-icon { - opacity: 1; - color: var(--color-accentedPalette-700); -} - -@media (prefers-color-scheme: dark) { - .sort-sheet__row_expanded { - background: rgba(255, 255, 255, 0.06); - } - - .sort-sheet__opt:active { - background: rgba(255, 255, 255, 0.08); - } - - .sort-sheet__opt_active { - background: var(--color-accentedPalette-900); - color: var(--color-accentedPalette-100); - } - - .sort-sheet__opt_active .mat-icon { - color: var(--color-accentedPalette-200); - } -} - -.columns-sheet__list { - display: flex; - flex-direction: column; -} - -.columns-sheet__item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 8px; - background: var(--mat-sidenav-content-background-color, #fff); -} - -.columns-sheet__item.cdk-drag-preview { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); -} - -.columns-sheet__item.cdk-drag-placeholder { - opacity: 0.3; -} - -.columns-sheet__drag-handle { - color: rgba(0, 0, 0, 0.4); - cursor: grab; - flex: 0 0 auto; -} - -.columns-sheet__checkbox { - flex: 1 1 auto; - min-width: 0; -} - -@media (prefers-color-scheme: dark) { - .columns-sheet__item { - background: var(--surface-dark-color); - } - - .columns-sheet__drag-handle { - color: rgba(255, 255, 255, 0.5); - } -} - -@keyframes tableSheetSlideUp { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -@keyframes tableSheetFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@media (prefers-color-scheme: dark) { - .table-sheet { - background: var(--surface-dark-color); - } - - .table-sheet__handle { - background: rgba(255, 255, 255, 0.24); - } - - .table-sheet__search { - background: rgba(255, 255, 255, 0.08); - } - - .table-sheet__search-input { - color: rgba(255, 255, 255, 0.87); - } - - .table-sheet__search-input::placeholder { - color: rgba(255, 255, 255, 0.5); - } - - .table-sheet__item:active { - background: rgba(255, 255, 255, 0.08); - } - - .table-sheet__item_active { - background: rgba(255, 255, 255, 0.08); - } -} - -.db-table-bulk-actions { - display: flex; - align-items: center; - height: 62.6px; -} - -@media (width <= 600px) { - .db-table-bulk-actions { - height: auto; - min-height: 48px; - padding: 8px 0; - } -} - -.search-input { - background-color: transparent; - /* margin-top: -12px; */ - min-width: 220px; -} - -.search-input ::ng-deep * { - background-color: transparent !important; -} - -.search-input ::ng-deep .mdc-text-field { - padding: 0 !important; -} - -.search-input ::ng-deep .mat-mdc-form-field-infix { - min-height: 0; -} - -.search-input - ::ng-deep - .mdc-text-field--no-label:not(.mdc-text-field--outlined):not(.mdc-text-field--textarea) - .mat-mdc-form-field-infix { - padding-top: 8px; - padding-bottom: 8px; -} - -.search-input ::ng-deep .mat-mdc-icon-button.mat-mdc-button-base { - width: 40px; - height: 40px; - padding: 8px; -} - -.search-input__icon { - opacity: 0.6; -} - -.search-row { - display: contents; -} - -.search-row__buttons { - display: contents; -} - -.search-row__filter, -.search-row__add-quick-filter, -.search-row__create-fast-filter, -.search-row__divider, -.search-row__quick-chips-slot { - display: none; -} - -.add-row-fab { - display: none; -} - -@media (width <= 600px) { - .add-row-fab { - display: inline-flex; - position: fixed; - right: 16px; - bottom: calc(16px + env(safe-area-inset-bottom, 0px)); - z-index: 50; - } -} - -.saved-filters-row__columns { - display: none; -} - -@media (width <= 600px) { - .search-row { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - width: 100%; - } - - .search-form { - display: flex; - align-items: center; - width: 100%; - height: 40px; - border-radius: 999px; - background: rgba(0, 0, 0, 0.06); - overflow: hidden; - } - - .search-row__buttons { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - } - - .search-row__filter { - display: inline-flex; - flex: 0 0 auto; - height: 32px; - line-height: 32px; - padding: 0 12px; - border-radius: 999px; - font-size: 13px; - } - - .search-row__filter .mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - .search-row__add-quick-filter { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0 !important; - --mdc-icon-button-state-layer-size: 32px; - --mat-icon-button-state-layer-size: 32px; - --mdc-icon-button-icon-size: 20px; - } - - .search-row__add-quick-filter .mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - margin: 0; - } - - .search-row__add-quick-filter ::ng-deep .mat-mdc-button-touch-target { - width: 32px; - height: 32px; - } - - .search-row__add-quick-filter ::ng-deep .mat-mdc-button-persistent-ripple, - .search-row__add-quick-filter ::ng-deep .mat-mdc-focus-indicator { - inset: 0; - border-radius: 50%; - } - - .search-row__create-fast-filter { - display: inline-flex; - flex: 0 0 auto; - height: 32px; - line-height: 32px; - padding: 0 12px; - border-radius: 999px; - font-size: 13px; - gap: 4px; - } - - .search-row__create-fast-filter .mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - .db-table-header, - .db-table-actions, - .search-row { - min-width: 0; - } - - .search-row__buttons { - min-width: 0; - max-width: 100%; - overflow: hidden; - } - - .search-row__quick-chips-slot { - position: relative; - display: block; - flex: 1 1 0; - min-width: 0; - width: 0; - max-width: 100%; - height: 32px; - overflow: hidden; - } - - .search-row__quick-chips { - position: absolute; - inset: 0; - display: flex; - align-items: center; - gap: 6px; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .search-row__quick-chips::-webkit-scrollbar { - display: none; - } - - .search-row__quick-chip { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - border: 1px solid var(--mdc-outlined-button-outline-color, rgba(0, 0, 0, 0.12)); - background: transparent; - color: var(--color-primaryPalette-700); - font-size: 13px; - font-weight: 500; - padding: 0 8px 0 12px; - height: 32px; - line-height: 30px; - border-radius: 999px; - white-space: nowrap; - cursor: pointer; - } - - .search-row__quick-chip_active { - background: var(--color-accentedPalette-500); - border-color: var(--color-accentedPalette-500); - color: var(--color-accentedPalette-500-contrast); - } - - .search-row__quick-chip-menu { - background: transparent; - border: 0; - padding: 0; - margin-left: 4px; - width: 20px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - color: inherit; - opacity: 0.7; - } - - .search-row__quick-chip-menu .mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - - @media (prefers-color-scheme: dark) { - .search-row__quick-chip { - border-color: var(--mdc-outlined-button-outline-color, rgba(255, 255, 255, 0.24)); - color: var(--color-primaryPalette-100); - } - } - - .search-row__divider { - display: inline-block; - flex: 0 0 auto; - width: 1px; - align-self: stretch; - margin: 4px 4px; - background: var(--color-primaryPalette-200); - } - - @media (prefers-color-scheme: dark) { - .search-row__divider { - background: var(--color-primaryPalette-700); - } - } - - .saved-filters-row__columns { - display: inline-flex; - flex: 0 0 auto; - } - - .actions__filter, - .actions__add-row, - .db-table-manage-columns-button { - display: none !important; - } - - .search-input { - width: 100%; - min-width: 0; - height: 40px; - background: transparent !important; - } - - .search-input ::ng-deep .mat-mdc-text-field-wrapper { - background: transparent !important; - padding: 0 8px 0 4px; - height: 40px; - } - - .search-input ::ng-deep .mdc-line-ripple, - .search-input ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - .search-input__icon { - font-size: 22px; - width: 22px; - height: 22px; - opacity: 0.55; - } - - .search-input ::ng-deep input.mat-mdc-input-element { - font-size: 14px; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .search-form { - background: rgba(255, 255, 255, 0.08); - } - - .search-form:focus-within, - .search-form_filled { - background: rgba(255, 255, 255, 0.05); - } -} - -.db-table-actions { - flex-grow: 1; - display: flex; - align-items: center; - justify-content: space-between; - gap: 4px; -} - -@media (width <= 600px) { - .db-table-actions { - flex-direction: column; - align-items: flex-start; - } -} - -.actions { - display: flex; - align-items: center; - gap: 8px; - margin-top: -16px; -} - -.saved-filters-row { - display: contents; -} - -.mobile-sort-button { - display: none !important; -} - -.saved-filters-row__mobile-actions { - display: none; -} - -@media (width <= 600px) { - .skeleton.mat-elevation-z4 { - box-shadow: none !important; - background: transparent !important; - } - - .saved-filters-row { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: flex-end; - gap: 4px 8px; - width: 100%; - max-width: 100%; - min-width: 0; - overflow: hidden; - } - - .saved-filters-row > app-saved-filters-panel { - flex: 0 1 auto; - min-width: 0; - max-width: 100%; - display: block; - overflow: hidden; - } - - .saved-filters-row:has(.filters-container) > app-saved-filters-panel { - flex: 0 0 100%; - order: 1; - } - - .saved-filters-row:has(.filters-container) > .saved-filters-row__columns, - .saved-filters-row:has(.filters-container) > .mobile-sort-button { - order: 2; - } - - .saved-filters-row__mobile-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - /* grow to fill the row so the buttons sit on the far right; panel keeps its natural width to avoid overlapping .saved-filters-trigger_active */ - flex: 1 1 auto; - min-width: 0; - width: 100%; - } - - .mobile-sort-button { - display: inline-flex !important; - align-items: center; - gap: 2px; - flex: 0 0 auto; - min-height: 36px; - padding: 0 4px; - font-size: 13px; - font-weight: 500; - --mdc-text-button-label-text-color: rgba(0, 0, 0, 0.87); - --mat-mdc-button-persistent-ripple-color: rgba(0, 0, 0, 0.87); - color: rgba(0, 0, 0, 0.87) !important; - } - - .mobile-sort-button .mat-icon, - .mobile-sort-button .mat-mdc-button-touch-target ~ * { - color: inherit !important; - } - - .mobile-sort-button ::ng-deep .mat-mdc-button > .mat-icon, - .mobile-sort-button ::ng-deep .mdc-button__label > .mat-icon, - .mobile-sort-button .mat-icon { - margin-right: 2px !important; - margin-left: 0 !important; - } - - .mobile-sort-button ::ng-deep .mdc-button__label { - display: inline-flex; - align-items: center; - gap: 2px; - } - - .mobile-sort-button.mobile-sort-button_active { - --mdc-text-button-label-text-color: var(--color-accentedPalette-700); - color: var(--color-accentedPalette-700) !important; - } - - .saved-filters-row__columns { - display: inline-flex !important; - align-items: center; - justify-content: center; - flex: 0 0 auto; - } - - .mobile-sort-button__label { - white-space: nowrap; - } - - .mobile-sort-button_active { - color: var(--color-accentedPalette-700); - } - - .actions { - flex-wrap: nowrap; - gap: 0; - width: 100%; - justify-content: flex-end; - margin-top: 0; - } - - .actions .action-label { - display: none; - } - - .actions .db-table-manage-columns-button__count { - display: none; - } - - .actions .mat-mdc-button { - --mdc-text-button-container-height: 40px; - min-width: 40px; - padding: 0 8px; - } - - .actions .mat-mdc-button .mat-icon { - margin: 0 !important; - } - - .ai-insights-button { - margin-left: 0; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .mobile-sort-button { - color: rgba(255, 255, 255, 0.87); - } - - .mobile-sort-button_active { - background: var(--color-accentedPalette-900); - border-color: var(--color-accentedPalette-600) !important; - color: var(--color-accentedPalette-100); - } -} - -.action_active { - position: relative; -} - -.action_active::after { - content: ""; - position: absolute; - top: 9px; - right: 3px; - display: block; - background: var(--color-accentedPalette-500); - border-radius: 50%; - height: 5px; - width: 5px; -} - -.action-button_disabled { - --mat-theme-text-disabled-on-background: rgba(0, 0, 0, 0.78); - - pointer-events: none; -} - -.ai-insights-button { - order: 999; - margin-left: auto; -} - -@media (prefers-color-scheme: light) { - .ai-insights-button { - background: var(--color-accentedPalette-50); - } - - .ai-insights-button:hover { - background: var(--color-accentedPalette-100) !important; - } -} - -@media (prefers-color-scheme: dark) { - .ai-insights-button { - background: var(--color-accentedPalette-900); - } - - .ai-insights-button:hover { - background: var(--color-accentedPalette-800) !important; - } -} - -@media (prefers-color-scheme: dark) { - .ai-icon ::ng-deep svg path { - fill: #fff; - } -} - -.db-table-manage-columns-button { - display: inline-block; -} - -.active-filters { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; -} - -@media (width <= 600px) { - .active-filters { - margin-top: -12px; - margin-bottom: 16px; - } -} - -::ng-deep .active-filters .mat-mdc-chip { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - --mdc-chip-container-shape-radius: 16px !important; - background-color: #e8ecee !important; - border-radius: 16px !important; -} - -::ng-deep .active-filters .mat-mdc-chip:hover { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; -} - -::ng-deep .active-filters .mdc-evolution-chip__cell { - background-color: #e8ecee !important; - border-radius: 16px !important; -} - -::ng-deep .active-filters .mdc-evolution-chip__cell:hover { - background-color: #e8ecee !important; -} - -@media (prefers-color-scheme: dark) { - ::ng-deep .active-filters .mat-mdc-chip { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - --mdc-chip-label-text-color: rgba(255, 255, 255, 0.87) !important; - background-color: #3a4a5c !important; - color: rgba(255, 255, 255, 0.87) !important; - } - - ::ng-deep .active-filters .mat-mdc-chip:hover { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } - - ::ng-deep .active-filters .mdc-evolution-chip__cell { - background-color: #3a4a5c !important; - } - - ::ng-deep .active-filters .mdc-evolution-chip__cell:hover { - background-color: #3a4a5c !important; - } -} - -.db-table-manage-columns-button__count { - margin-left: 2px; -} - -@media (prefers-color-scheme: dark) { - .db-table-manage-columns-button__count { - color: rgba(255, 255, 255, 0.56); - } -} - -@media (prefers-color-scheme: light) { - .db-table-manage-columns-button__count { - color: rgba(0, 0, 0, 0.52); - } -} - -.db-table-active-filter-chip ::ng-deep .mdc-evolution-chip__text-label { - max-width: 300px; - overflow: hidden; -} - -.empty-table-message { - display: inline-block; - margin-top: 40px; - text-align: center; - width: 100%; -} - -.table-wrapper { - position: relative; - min-height: 100px; - width: calc(86vw - 4rem); - transition: width 400ms; -} - -@media (width <= 600px) { - .table-wrapper { - width: calc(98vw - 292px); - } -} - -.table-surface { - margin-bottom: 72px; -} - -@media (width <= 600px) { - .table-surface mat-paginator { - margin-top: 12px; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-outer-container, - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-container { - justify-content: space-between !important; - flex-wrap: nowrap !important; - min-height: 48px; - padding: 0 !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size { - margin: 0 !important; - padding: 0 !important; - margin-right: auto !important; - flex: 0 0 auto; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-label { - margin: 0 4px 0 0 !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-select { - margin: 0 8px 0 0 !important; - width: auto !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-form-field-infix { - width: auto !important; - min-width: 36px !important; - padding-right: 4px !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-select { - width: auto !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-select-arrow-wrapper { - padding-left: 6px !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-text-field-wrapper { - padding: 0 10px !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-range-actions { - flex: 0 0 auto; - margin: 0 !important; - } - - .table-surface mat-paginator ::ng-deep .mat-mdc-paginator-range-label { - margin: 0 4px !important; - } -} - -@media (prefers-color-scheme: dark) { - .table-surface { - --mat-table-background-color: var(--surface-dark-color); - --mat-paginator-container-background-color: var(--surface-dark-color); - } -} - -.table-box { - overflow-x: auto; - width: 100%; -} - -.db-table { - display: grid; - width: 100%; - font-family: "IBM Plex Mono", monospace; - --mat-table-header-headline-font: "IBM Plex Mono", monospace; - --mat-table-row-item-label-text-font: "IBM Plex Mono", monospace; - --mat-table-footer-supporting-text-font: "IBM Plex Mono", monospace; -} - -@media (width <= 600px) { - .db-table { - grid-template-columns: minmax(0, 120px) 1fr !important; - grid-row-gap: 8px; - background: transparent !important; - } - - .db-table ::ng-deep tbody { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - row-gap: 16px; - background: transparent; - } - - .table-surface { - background: transparent !important; - box-shadow: none !important; - } - - .table-surface ::ng-deep mat-paginator { - margin-top: 20px; - } - - .table-box { - background: transparent; - } -} - -.db-table_withActions { - grid-template-columns: 48px repeat(var(--colCount), fit-content(100%)) minmax(var(--lastColumnWidth), auto); -} - -.db-table_withoutActions { - grid-template-columns: repeat(var(--colCount), auto) !important; -} - -.db-table ::ng-deep .db-table-row { - cursor: pointer; -} - -.db-table ::ng-deep .db-table-row:hover { - background-color: var(--hover-color) !important; -} - -@media (prefers-color-scheme: dark) { - .db-table ::ng-deep .db-table-row:hover { - --hover-color: var(--color-primaryPalette-900); - } -} - -@media (prefers-color-scheme: light) { - .db-table ::ng-deep .db-table-row:hover { - --hover-color: var(--color-primaryPalette-50); - } -} - -.db-table-row_selected { - background-color: var(--selected-color); -} - -@media (prefers-color-scheme: dark) { - .db-table-row_selected { - --selected-color: var(--color-primaryPalette-900); - } -} - -@media (prefers-color-scheme: light) { - .db-table-row_selected { - --selected-color: var(--color-primaryPalette-100); - } -} - -@media (width <= 600px) { - .db-table-row_selected { - background-color: initial; - } -} - -.db-table-row, -.db-table-header-row, -.db-table ::ng-deep .mat-mdc-footer-row { - display: contents; - min-height: 0; -} - -@media (width <= 600px) { - .db-table-row { - position: relative; - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - grid-gap: 2px 12px; - border: 1px solid var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)) !important; - border-radius: 10px; - height: auto; - padding: 8px 8px 8px 16px; - } - - .db-table-header-row { - display: none; - } -} - -.db-table-cell, -.db-table ::ng-deep .mat-mdc-header-cell, -.db-table ::ng-deep .mat-mdc-footer-cell { - border-bottom-width: 1px; - border-bottom-style: solid; - border-bottom-color: var(--mat-table-row-item-outline-color); - padding-right: 24px; - min-height: 36px; -} - -@media (width <= 600px) { - .db-table-cell { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - align-items: center; - border-bottom: 1px dashed rgba(0, 0, 0, 0.12); - padding-left: 0 !important; - padding-right: 0 !important; - padding-top: 0; - padding-bottom: 0; - } - - .db-table-cell:not(:has(~ .db-table-cell)) { - border-bottom: none; - } - - .db-table-cell::before { - content: attr(data-label); - display: inline-block; - font-weight: 500; - font-size: 12px; - white-space: wrap; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .db-table-cell { - border-bottom-color: rgba(255, 255, 255, 0.12); - } -} - -.db-table ::ng-deep .db-table-cell:first-of-type, -.db-table ::ng-deep .mat-mdc-header-cell:first-of-type, -.db-table ::ng-deep .mat-mdc-footer-cell:first-of-type { - padding-left: 16px; - padding-right: 8px; -} - -@media (width <= 600px) { - .db-table ::ng-deep .db-table-cell:first-of-type, - .db-table ::ng-deep .mat-mdc-header-cell:first-of-type, - .db-table ::ng-deep .mat-mdc-footer-cell:first-of-type { - padding-left: 0; - margin-top: 4px; - } - - /* .db-table-cell { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - align-items: center; - border-bottom: none; - } - - .db-table-cell::before { - content: attr(data-label); - display: inline-block; - font-weight: bold; - white-space: nowrap; - } */ -} - -.db-table_withActions ::ng-deep .mat-column-select { - padding-top: 2px; - padding-left: 4px !important; - padding-right: 4px !important; -} - -.db-table_withActions ::ng-deep .mat-header-row .mat-column-select { - padding-top: 18px; -} - -th.mat-header-cell, -td.mat-cell { - padding-right: 20px; -} - -.db-table ::ng-deep .mat-mdc-header-cell .mat-sort-header-content { - text-align: left; -} - -/* Sortable header styles */ -.sortable-header__content { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - gap: 4px; -} - -.sortable-header__text { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sortable-header__button { - position: absolute; - right: 0; - opacity: 0; - width: 24px; - height: 24px; - line-height: 24px; - transition: opacity 0.2s ease; -} - -@media (prefers-color-scheme: light) { - .sortable-header__button { - background: rgba(255, 255, 255, 1); - } -} - -@media (prefers-color-scheme: dark) { - .sortable-header__button { - background: rgba(32, 32, 32, 1); - } -} - -.sortable-header:hover .sortable-header__button, -.sortable-header_active .sortable-header__button { - opacity: 1; -} - -.sortable-header__icon { - font-size: 18px; - width: 18px; - height: 18px; - color: rgba(0, 0, 0, 0.54); -} - -.sortable-header_active .sortable-header__icon { - color: var(--color-primaryPalette-500); -} - -@media (prefers-color-scheme: dark) { - .sortable-header__icon { - color: rgba(255, 255, 255, 0.54); - } - - .sortable-header_active .sortable-header__icon { - color: var(--color-primaryPalette-300); - } -} - -/* Sort menu styles */ -.sort-menu__item { - display: flex; - align-items: center; - gap: 8px; -} - -.sort-menu__item mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.sort-menu__item-content { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 10px; - border-radius: 4px; - margin: -4px -10px; -} - -.sort-menu__item-content_active { - background-color: rgba(0, 0, 0, 0.04); -} - -@media (prefers-color-scheme: dark) { - .sort-menu__item-content_active { - background-color: rgba(255, 255, 255, 0.06); - } -} - -.sort-menu__lock-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.db-table-cell-checkbox { - display: flex; - align-items: center; -} - -@media (prefers-color-scheme: light) { - .db-table-cell-actions { - color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .db-table-cell-actions { - color: rgba(255, 255, 255, 0.64); - } -} - -@media (width <= 600px) { - .db-table-cell-checkbox { - position: absolute; - top: 10px; - left: 8px; - border-bottom: none; - z-index: 2; - } - - .db-table-checkbox { - transform: scale(1.1); - } - - .db-table-cell-actions { - grid-row: 1; - grid-column: 1 / span 2; - justify-content: flex-end; - gap: 4px; - border-bottom: none; - padding-left: 40px; - padding-right: 0 !important; - margin-right: -8px; - color: rgba(0, 0, 0, 0.87); - } - - .db-table-cell-action-button { - --mat-icon-button-state-layer-size: 36px !important; - --mat-icon-button-icon-size: 22px !important; - } - - .db-table-cell-action-button ::ng-deep .mat-icon { - font-size: 22px; - height: 22px; - width: 22px; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .db-table-cell-actions { - color: rgba(255, 255, 255, 0.87); - } -} - -.db-table-cell-actions, -.db-table-header-actions { - justify-content: flex-end; -} - -.db-table-cell-action-button { - --mat-icon-button-state-layer-size: 30px !important; - --mat-icon-button-icon-size: 20px !important; -} - -.db-table-cell-action-button ::ng-deep .mat-icon { - font-size: 20px; - height: 20px; - width: 20px; -} - -.table-cell-content { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - width: 100%; -} - -@media (width <= 600px) { - .table-cell-content { - white-space: wrap; - max-height: 5lh; - } -} - -@media (prefers-color-scheme: light) { - .table-cell-content { - color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .table-cell-content { - color: rgba(255, 255, 255, 0.64); - } -} - -.db-table-cell:hover .field-value-copy-button { - display: initial; -} - -tr.mat-row:hover { - background: rgba(0, 0, 0, 0.04); -} - -.mat-mdc-table-sticky { - padding-left: 16px; - padding-right: 16px !important; -} - -.spinner-wrapper { - position: absolute; - top: 76px; - left: 0; - display: flex; - justify-content: center; - background: rgba(255, 255, 255, 0.9); - height: 500px; - width: 100%; - z-index: 2; -} - -.spinner-wrapper ::ng-deep.mat-spinner { - position: relative; - top: 40px; -} - -.empty-table { - display: flex; - flex-direction: column; - align-items: center; - padding: 24px 0 8px; - width: 100%; -} - -.empty-table .mat-button { - border-radius: 0; - margin: -20px 0; - padding: 8px 0; - width: 100%; -} - -.empty-table__icon { - font-size: 32px; - width: 32px; - height: 32px; - margin-bottom: 8px; - opacity: 0.5; -} - -.bulk-actions__select-all { - display: none !important; -} - -@media (width <= 600px) { - .bulk-actions__select-all { - display: inline-flex !important; - } -} - -@media (width <= 600px) { - .empty-table { - align-items: center; - padding: 24px 16px; - border: 1px solid var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); - border-radius: 10px; - background: transparent; - margin: 0; - text-align: center; - } - - .empty-table .mat-body-1 { - margin: 0; - padding: 0 4px; - } - - .empty-table .mat-button { - margin-top: 12px; - width: auto; - border-radius: 999px; - } -} - -.hidden { - display: none; -} - -/* Columns Menu Drag and Drop Styles */ -.columns-list { - max-height: 400px; - overflow-y: auto; - padding: 8px 0; -} - -.draggable-column-item { - cursor: move; - display: flex !important; - align-items: center; - justify-content: flex-start; - gap: 0px; - padding: 8px 24px 8px 8px !important; - min-height: 48px !important; - transition: background-color 0.2s ease; -} - -.draggable-column-item .drag-handle { - color: #9e9e9e; - cursor: grab; - font-size: 20px; - width: 20px; - height: 20px; - margin-right: 0px; - flex-shrink: 0; -} - -.draggable-column-item ::ng-deep .mat-mdc-checkbox { - align-items: center !important; -} - -.draggable-column-item ::ng-deep .mat-mdc-checkbox-label { - text-align: left; - line-height: 1.5; -} - -.draggable-column-item ::ng-deep .mdc-form-field { - align-items: center !important; -} - -.draggable-column-item:active .drag-handle { - cursor: grabbing; -} - -.draggable-column-item:hover .drag-handle { - color: #616161; -} - -@media (prefers-color-scheme: dark) { - .draggable-column-item .drag-handle { - color: #757575; - } - - .draggable-column-item:hover .drag-handle { - color: #bdbdbd; - } -} - -.cdk-drag-preview.draggable-column-item { - box-shadow: - 0 5px 5px -3px rgba(0, 0, 0, 0.2), - 0 8px 10px 1px rgba(0, 0, 0, 0.14), - 0 3px 14px 2px rgba(0, 0, 0, 0.12); - opacity: 0.9; -} - -.cdk-drag-placeholder { - opacity: 0.3; -} - -.cdk-drag-animating { - transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); -} - -.columns-list.cdk-drop-list-dragging .draggable-column-item:not(.cdk-drag-placeholder) { - transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html deleted file mode 100644 index 319bb13d1..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html +++ /dev/null @@ -1,752 +0,0 @@ -
- - refresh - -
- -
-
-

{{ displayName }}

- - - - - - - - -
-
-
- - - - - - -
-
- -
-
-
- - search - - - -
-
- - - - - -
-
- -
- {{ f.name }} - -
-
-
- - - - -
-
-
- - - - add - Add row - - - - -
- - -
- -
-
-
- - - - - - -
-
-
- - - - UI Widgets - - - Automations -
- - Primary keys are required. - -
- - Settings - - - - - -
-
- - - add - Add row - - -
- - {{ getFilter(activeFilter) }} - - -
- -
-
- - - -
- - -
- - -
- -
- -
-
- - - - - - - - - - - - - - - -
- {{ tableData.dataNormalizedColumns[column] }} - -
- - - - - - -
- -
- - - - - - - - - - - - -
-
-
- - - Actions - - - - - - - create - - - difference - - - - - - - > - - -
-
- -
-

No field matches "{{staticSearchString}}".

-

If you want to find substring, please, use Filter.

-
- -
- filter_list_off -

- {{ hasSavedFilterActive ? 'No records match the selected fast filter' : 'No records match this filter' }} -

-
- - - - - - -
- - -
-
- -
- Select table - -
- - - -
- - - - - - - -
Others
- -
- -
- No tables match "{{ tableSwitcherSearch }}". -
-
-
- - -
-
- -
- Sort by - -
- -
- - - -
- - - -
-
-
-
- - -
-
- -
- Columns - -
- -
-
-
- drag_indicator - - {{ column.normalizedTitle }} - -
-
-
-
- - -
-
- -
- Import / Export - -
- -
- - -
-
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts deleted file mode 100644 index 11fba7e69..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { SelectionModel } from '@angular/cdk/collections'; -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginatorModule } from '@angular/material/paginator'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatSortModule } from '@angular/material/sort'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { TablesDataSource } from '../db-tables-data-source'; -import { DbTableViewComponent } from './db-table-view.component'; - -describe('DbTableViewComponent', () => { - let component: DbTableViewComponent; - let fixture: ComponentFixture; - - const _mockWidgets = { - Region: { - id: 'c768dde8-7348-46e8-a522-718a29b705e8', - field_name: 'Region', - widget_type: 'Select', - widget_params: { - options: [ - { - value: 'AK', - label: 'Alaska', - }, - { - value: 'CA', - label: 'California', - }, - ], - }, - widget_options: null, - name: 'State', - description: '', - }, - address_id: { - id: 'ee3125ca-86cc-4c20-93f9-8a0b2c43d61f', - field_name: 'address_id', - widget_type: 'String', - widget_params: '', - widget_options: null, - name: '', - description: '', - }, - }; - - const _mockSelectOption = { - Region: [ - { - value: 'AK', - label: 'Alaska', - }, - { - value: 'CA', - label: 'California', - }, - ], - }; - - const mockFilterComparators = { - first_name: 'startswith', - second_name: 'endswith', - email: 'contains', - age: 'gt', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatMenuModule, - MatSnackBarModule, - MatPaginatorModule, - BrowserAnimationsModule, - MatSortModule, - FormsModule, - MatDialogModule, - MatIconTestingModule, - Angulartics2Module.forRoot({}), - DbTableViewComponent, - ], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableViewComponent); - component = fixture.componentInstance; - component.table = new TablesDataSource({} as any, {} as any, {} as any); - component.selection = new SelectionModel(true, []); - component.filterComparators = mockFilterComparators; - fixture.autoDetectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should check if column is foreign key', () => { - component.tableData.foreignKeysList = ['ProductId', 'CustomerId']; - const isForeignKeyResult = component.isForeignKey('ProductId'); - expect(isForeignKeyResult).toBe(true); - }); - - it('should return query params for link for foreign key', () => { - const foreignKey = { - autocomplete_columns: ['FirstName'], - column_name: 'CustomerId', - column_default: null, - constraint_name: 'Orders_ibfk_2', - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - }; - - const cell = { - Id: 42, - }; - - const queryParams = component.getForeignKeyQueryParams(foreignKey, cell); - expect(queryParams).toEqual({ Id: 42 }); - }); - - it('should check if it is widget by column name', () => { - component.tableData.widgetsList = ['Name', 'Age']; - - const isWigetAge = component.isWidget('Age'); - - expect(isWigetAge).toBe(true); - }); - - it('should return 2 for active filters with object of two fileds', () => { - const numberOfFilters = component.getFiltersCount({ Country: 'С', Name: 'John' }); - - expect(numberOfFilters).toEqual(2); - }); - - it('should emit open filters actions to parent', () => { - const mockStructure = [ - { - column_name: 'first_name', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 45, - }, - ]; - const mockForeignKeysList = []; - const mockForeignKeys = {}; - const mockWidgets = []; - component.tableData.structure = mockStructure; - component.tableData.foreignKeysList = mockForeignKeysList; - component.tableData.foreignKeys = mockForeignKeys; - component.tableData.widgets = mockWidgets; - vi.spyOn(component.openFilters, 'emit'); - - component.handleOpenFilters(); - - expect(component.openFilters.emit).toHaveBeenCalledWith({ - structure: mockStructure, - foreignKeysList: mockForeignKeysList, - foreignKeys: mockForeignKeys, - widgets: mockWidgets, - }); - expect(component.searchString).toEqual(''); - }); - - it('should clear search string and emit search by string to parent', () => { - vi.spyOn(component.search, 'emit'); - - component.clearSearch(); - - expect(component.searchString).toBeNull(); - expect(component.search.emit).toHaveBeenCalledWith(null); - }); - - it('should return filter chip label for starts with comparator', () => { - const chipFilterLable = component.getFilter({ key: 'first_name', value: { startswith: 'A' } }); - - expect(chipFilterLable).toEqual('First Names = A...'); - }); - - it('should return filter chip label for ends with comparator', () => { - const chipFilterLable = component.getFilter({ key: 'second_name', value: { endswith: 'y' } }); - - expect(chipFilterLable).toEqual('Second Names = ...y'); - }); - - it('should return filter chip label for contains comparator', () => { - const chipFilterLable = component.getFilter({ key: 'email', value: { contains: 'gmail' } }); - - expect(chipFilterLable).toEqual('Emails = ...gmail...'); - }); - - it('should return filter chip label for greater than comparator', () => { - const chipFilterLable = component.getFilter({ key: 'age', value: { gt: '20' } }); - - expect(chipFilterLable).toEqual('Ages > 20'); - }); - - it('should return human readable value of foreign key if identitty field was not pointed', () => { - const foreignKey = { - autocomplete_columns: ['FirstName'], - column_name: 'CustomerId', - column_default: null, - constraint_name: 'Orders_ibfk_2', - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - }; - - const cell = { - Id: 42, - }; - - const value = component.getCellValue(foreignKey, cell); - expect(value).toEqual(42); - }); - - it('should return human readable value of foreign key if identity field was pointed', () => { - const foreignKey = { - autocomplete_columns: ['FirstName'], - column_name: 'CustomerId', - column_default: null, - constraint_name: 'Orders_ibfk_2', - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - }; - - const cell = { - Id: 42, - name: 'John', - }; - - const value = component.getCellValue(foreignKey, cell); - expect(value).toEqual('John'); - }); - - it('should return null (not throw) when foreign key cell is null', () => { - const foreignKey = { - autocomplete_columns: ['FirstName'], - column_name: 'CustomerId', - column_default: null, - constraint_name: 'Orders_ibfk_2', - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - }; - - expect(component.getCellValue(foreignKey, null)).toBeNull(); - expect(component.getCellValue(foreignKey, undefined)).toBeNull(); - }); - - it('should not throw in isForeignKeySelected when record is null', () => { - const foreignKey = { - autocomplete_columns: ['FirstName'], - column_name: 'CustomerId', - column_default: null, - constraint_name: 'Orders_ibfk_2', - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - }; - - expect(component.isForeignKeySelected(null, foreignKey)).toBe(false); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts deleted file mode 100644 index 8f151871c..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.ts +++ /dev/null @@ -1,1103 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { SelectionModel } from '@angular/cdk/collections'; -import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; -import { CommonModule } from '@angular/common'; -import { - ChangeDetectorRef, - Component, - computed, - EventEmitter, - HostListener, - Input, - inject, - OnChanges, - OnInit, - Output, - SimpleChanges, - signal, - ViewChild, -} from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginator, MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSort, MatSortModule } from '@angular/material/sort'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import { Angulartics2OnModule } from 'angulartics2'; -import JSON5 from 'json5'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import posthog from 'posthog-js'; -import { merge } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { formatFieldValue } from 'src/app/lib/format-field-value'; -import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; -import { CustomAction, TableForeignKey, TableOrdering, TableProperties, TableRow, Widget } from 'src/app/models/table'; -import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { tableDisplayTypes, UIwidgets } from '../../../consts/table-display-types'; -import { normalizeTableName } from '../../../lib/normalize'; -import { PlaceholderTableDataComponent } from '../../skeletons/placeholder-table-data/placeholder-table-data.component'; -import { ForeignKeyDisplayComponent } from '../../ui-components/table-display-fields/foreign-key/foreign-key.component'; -import { DbTableExportDialogComponent } from './db-table-export-dialog/db-table-export-dialog.component'; -import { DbTableFiltersDialogComponent } from './db-table-filters-dialog/db-table-filters-dialog.component'; -import { DbTableImportDialogComponent } from './db-table-import-dialog/db-table-import-dialog.component'; -import { SavedFiltersPanelComponent } from './saved-filters-panel/saved-filters-panel.component'; - -interface Column { - title: string; - selected: boolean; -} - -interface TableCategory { - category_id: string; - category_name: string; - category_color?: string; - tables: TableProperties[]; -} - -@Component({ - selector: 'app-db-table-view', - standalone: true, - templateUrl: './db-table-view.component.html', - styleUrls: ['./db-table-view.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatTableModule, - MatPaginatorModule, - MatSortModule, - MatButtonModule, - MatIconModule, - MatCheckboxModule, - MatChipsModule, - MatDialogModule, - MatDividerModule, - MatFormFieldModule, - MatSelectModule, - ReactiveFormsModule, - MatInputModule, - MatAutocompleteModule, - MatMenuModule, - MatTooltipModule, - ClipboardModule, - DragDropModule, - Angulartics2OnModule, - PlaceholderTableDataComponent, - DynamicModule, - SignalComponentIoModule, - ForeignKeyDisplayComponent, - SavedFiltersPanelComponent, - ], -}) -export class DbTableViewComponent implements OnInit, OnChanges { - protected posthog = posthog; - @Input() name: string; - @Input() displayName: string; - @Input() connectionID: string; - @Input() isTestConnection: boolean; - @Input() activeFilters: object; - @Input() filterComparators: object; - @Input() selection: SelectionModel; - @Input() tableFolders: TableCategory[] = []; - - @Output() openFilters = new EventEmitter(); - @Output() openPage = new EventEmitter(); - @Output() search = new EventEmitter(); - @Output() removeFilter = new EventEmitter(); - @Output() resetAllFilters = new EventEmitter(); - // @Output() viewRow = new EventEmitter(); - - public hasSavedFilterActive: boolean = false; - @Output() activateAction = new EventEmitter(); - @Output() activateActions = new EventEmitter(); - - @Output() applyFilter = new EventEmitter(); - - private _permissions = inject(CedarPermissionService); - private _tableResourceId = signal(''); - - protected canEditRow = this._tablePermission('table:edit'); - protected canAddRow = this._tablePermission('table:add'); - protected canDeleteRow = this._tablePermission('table:delete'); - protected canEditConnection = () => this._connections.canEditConnection(); - - protected get actionsColumnWidth(): string { - const editCount = this.canEditRow() ? 1 : 0; - const addCount = this.canAddRow() ? 1 : 0; - const deleteCount = this.canDeleteRow() && this.tableData?.canDelete ? 1 : 0; - const defaultActionsCount = editCount + addCount + deleteCount; - const totalActionsCount = (this.tableData?.tableActions?.length || 0) + defaultActionsCount; - const lengthValue = totalActionsCount * 30 + 32; - return totalActionsCount === 0 ? '0' : `${lengthValue}px`; - } - - // public tablesSwitchControl = new FormControl(''); - public tableData: any; - // public selection: any; - public columns: Column[]; - public displayedColumns: string[] = []; - public columnsToDisplay: string[] = []; - public searchString: string; - public staticSearchString: string; - public bulkActions: CustomAction[]; - public bulkRows: string[]; - public displayedComparators = { - eq: '=', - gt: '>', - lt: '<', - gte: '>=', - lte: '<=', - }; - public selectedRow: TableRow = null; - public selectedRowType: 'record' | 'foreignKey' = 'record'; - public tableRelatedRecords: any = null; - public displayCellComponents; - public UIwidgets = UIwidgets; - // public tableTypes: object; - - @Input() set table(value) { - if (value) this.tableData = value; - } - - @ViewChild(MatPaginator) paginator: MatPaginator; - @ViewChild(MatSort) sort: MatSort; - @ViewChild(SavedFiltersPanelComponent) savedFiltersPanel?: SavedFiltersPanelComponent; - - public chipFilterForMenu: any = null; - - public defaultSort: { column: string; direction: 'asc' | 'desc' } | null = null; - private sortInitialized: boolean = false; - - constructor( - private _tableState: TableStateService, - private _notifications: NotificationsService, - private _tableRow: TableRowService, - private _connections: ConnectionsService, - private _tables: TablesService, - private route: ActivatedRoute, - public router: Router, - public dialog: MatDialog, - private cdr: ChangeDetectorRef, - private paginatorIntl: MatPaginatorIntl, - ) { - this.paginatorIntl.itemsPerPageLabel = 'Rows per page:'; - this.paginatorIntl.changes.next(); - } - - ngAfterViewInit() { - this.tableData.paginator = this.paginator; - - // Check if sort params exist in URL - const urlSortActive = this.route.snapshot.queryParams.sort_active; - const urlSortDirection = this.route.snapshot.queryParams.sort_direction; - - // Subscribe to loading state to initialize default sort after data is loaded - this.tableData.loading$.subscribe((loading: boolean) => { - console.log( - 'loading$ changed:', - loading, - 'sort.active:', - this.sort?.active, - 'sort.direction:', - this.sort?.direction, - ); - - if (!loading && this.tableData.defaultSort !== undefined) { - console.log('DbTableViewComponent tableData loaded:', this.tableData); - console.log('DbTableViewComponent tableData.defaultSort loaded:', this.tableData.defaultSort); - - // Only initialize sort and defaultSort on first load (or after table switch when sortInitialized was reset) - if (!this.sortInitialized) { - this.sortInitialized = true; - - // Only sync defaultSort from server on initial load, not on subsequent sort/pagination changes - this.defaultSort = this.tableData.defaultSort; - - // Initialize sort based on priority: URL params > default sort - if (urlSortActive && urlSortDirection) { - // Use sort from URL - this.sort.active = urlSortActive; - this.sort.direction = urlSortDirection.toLowerCase() as 'asc' | 'desc'; - } else if (this.defaultSort) { - // Use default sort if no URL params - this.sort.active = this.defaultSort.column; - this.sort.direction = this.defaultSort.direction; - } - } - - console.log( - 'After loading complete - sort.active:', - this.sort?.active, - 'sort.direction:', - this.sort?.direction, - ); - } - }); - - merge(this.sort.sortChange, this.paginator.page) - .pipe( - tap(() => { - const filters = JsonURL.stringify(this.activeFilters); - const saved_filter = this.route.snapshot.queryParams.saved_filter; - const dynamic_column = this.route.snapshot.queryParams.dynamic_column; - - this.router.navigate([`/dashboard/${this.connectionID}/${this.name}`], { - queryParams: { - filters, - saved_filter, - dynamic_column, - sort_active: this.sort.active, - sort_direction: this.sort.direction.toUpperCase(), - page_index: this.paginator.pageIndex, - page_size: this.paginator.pageSize, - }, - }); - this.loadRowsPage(); - }), - ) - .subscribe(); - } - - ngOnChanges(changes: SimpleChanges) { - // Update table resource ID for Cedar permission signals - if (changes.name || changes.connectionID) { - this._tableResourceId.set(`${this.connectionID}/${this.name}`); - } - - // When table name changes, reset sort to default - if (changes.name && !changes.name.firstChange && this.sort) { - console.log('Table name changed from', changes.name.previousValue, 'to', changes.name.currentValue); - - // Reset sort to empty state - it will be initialized with default sort when data loads - this.sort.active = ''; - this.sort.direction = '' as any; - - // Reset defaultSort so the previous table's default doesn't show on the new table - this.defaultSort = null; - - // Reset the sortInitialized flag so the sort gets re-initialized with new table's default sort - this.sortInitialized = false; - } - } - - ngOnInit() { - this.searchString = this.route.snapshot.queryParams.search; - // this.hasSavedFilterActive = !!this.route.snapshot.queryParams.saved_filter; - - // On mobile, never restore a previously open AI panel — user should land on the table, not the chat. - if (this.isMobileView) { - this._tableState.closeAIpanel(); - } - - const connectionType = this._connections.currentConnection.type; - this.displayCellComponents = tableDisplayTypes[connectionType]; - - this._tableState.cast.subscribe((row) => { - this.selectedRow = row; - }); - - this.route.queryParams.subscribe((params) => { - this.hasSavedFilterActive = !!params.saved_filter; - if (this.hasSavedFilterActive) this.searchString = ''; - }); - } - - get allTables(): TableProperties[] { - return this.tableFolders?.find((cat) => cat.category_id === null)?.tables || []; - } - - public tableSwitcherSearch: string = ''; - - get tableFoldersForSelect(): TableCategory[] { - if (!this.tableFolders) return []; - return this.tableFolders.filter((cat) => cat.category_id !== null); - } - - get uncategorizedTables(): TableProperties[] { - const tablesInFolders = new Set(); - this.tableFoldersForSelect.forEach((folder) => { - (folder.tables || []).forEach((t) => tablesInFolders.add(t.table)); - }); - return this.allTables.filter((t) => !tablesInFolders.has(t.table)); - } - - get filteredTableFoldersForSelect(): TableCategory[] { - const query = this.tableSwitcherSearch.trim().toLowerCase(); - if (!query) return this.tableFoldersForSelect; - return this.tableFoldersForSelect - .map((folder) => ({ - ...folder, - tables: (folder.tables || []).filter((t) => this._matchesTableQuery(t, query)), - })) - .filter((folder) => folder.tables.length > 0); - } - - get filteredUncategorizedTables(): TableProperties[] { - const query = this.tableSwitcherSearch.trim().toLowerCase(); - if (!query) return this.uncategorizedTables; - return this.uncategorizedTables.filter((t) => this._matchesTableQuery(t, query)); - } - - @ViewChild('tableSwitcherSearchInput') - tableSwitcherSearchInputRef?: { nativeElement: HTMLInputElement }; - - public tableSwitcherOpen = false; - public collapsedFolders = new Set(); - public sortSheetOpen = false; - public sortExpandedColumn: string | null = null; - public columnsSheetOpen = false; - - get isMobileView(): boolean { - return typeof window !== 'undefined' && window.innerWidth <= 600; - } - - openSortSheet() { - this.sortSheetOpen = true; - } - - closeSortSheet() { - this.sortSheetOpen = false; - this.sortExpandedColumn = null; - } - - toggleSortColumn(column: string) { - this.sortExpandedColumn = this.sortExpandedColumn === column ? null : column; - } - - openColumnsSheet() { - this.columnsSheetOpen = true; - } - - closeColumnsSheet() { - this.columnsSheetOpen = false; - } - - public transferSheetOpen = false; - - openTransferSheet() { - this.transferSheetOpen = true; - } - - closeTransferSheet() { - this.transferSheetOpen = false; - } - - get sortableDataColumns(): string[] { - return (this.tableData?.displayedDataColumns || []).filter((c: string) => this.isSortable(c)); - } - - clearTableSwitcherSearch() { - this.tableSwitcherSearch = ''; - } - - toggleFolderCollapse(folderId: string) { - if (this.collapsedFolders.has(folderId)) { - this.collapsedFolders.delete(folderId); - } else { - this.collapsedFolders.add(folderId); - } - } - - isFolderCollapsed(folderId: string): boolean { - if (this.tableSwitcherSearch.trim()) return false; - return this.collapsedFolders.has(folderId); - } - - openTableSwitcher() { - this.tableSwitcherOpen = true; - setTimeout(() => { - this.tableSwitcherSearchInputRef?.nativeElement.focus(); - }, 50); - } - - closeTableSwitcher() { - this.tableSwitcherOpen = false; - this.clearTableSwitcherSearch(); - } - - switchTableFromSheet(tableName: string) { - this.closeTableSwitcher(); - this.switchTable(tableName); - } - - loadRowsPage() { - this.tableRelatedRecords = null; - this.tableData.fetchRows({ - connectionID: this.connectionID, - tableName: this.name, - requstedPage: this.paginator.pageIndex, - pageSize: this.paginator.pageSize, - sortColumn: this.sort.active, - sortOrder: this.sort.direction.toUpperCase(), - filters: this.activeFilters, - search: this.searchString, - isTablePageSwitched: true, - }); - } - - public pullDistance = 0; - public pullRefreshing = false; - private _pullStartY = 0; - private _pullTracking = false; - private readonly _pullThreshold = 70; - - @HostListener('touchstart', ['$event']) - onPullTouchStart(event: TouchEvent) { - if (!this.isMobileView || this.pullRefreshing) return; - const scrollTop = window.scrollY || document.documentElement.scrollTop || 0; - if (scrollTop > 0) return; - this._pullStartY = event.touches[0].clientY; - this._pullTracking = true; - } - - @HostListener('touchmove', ['$event']) - onPullTouchMove(event: TouchEvent) { - if (!this._pullTracking || this.pullRefreshing) return; - const delta = event.touches[0].clientY - this._pullStartY; - if (delta <= 0) { - this.pullDistance = 0; - return; - } - this.pullDistance = Math.min(delta * 0.5, this._pullThreshold + 20); - } - - @HostListener('touchend') - onPullTouchEnd() { - if (!this._pullTracking) return; - this._pullTracking = false; - if (this.pullDistance >= this._pullThreshold) { - this.pullRefreshing = true; - this.pullDistance = this._pullThreshold; - this.loadRowsPage(); - setTimeout(() => { - this.pullRefreshing = false; - this.pullDistance = 0; - }, 800); - } else { - this.pullDistance = 0; - } - } - - isSortable(column: string) { - return this.tableData.sortByColumns.includes(column) || !this.tableData.sortByColumns.length; - } - - applySort(column: string, direction: 'asc' | 'desc') { - // If clicking on already selected sort - clear it - if (this.sort.active === column && this.sort.direction === direction) { - // If this column was the default, remove the default too - if (this.defaultSort?.column === column) { - this.defaultSort = null; - } - // Clear sort - this.sort.active = ''; - this.sort.direction = ''; - this.sort.sortChange.emit({ active: '', direction: '' }); - } else { - this.sort.active = column; - this.sort.direction = direction; - this.sort.sortChange.emit({ active: column, direction: direction }); - } - } - - toggleDefaultSort(column: string) { - if (this.isDefaultSort(column)) { - // Remove default sort - this.defaultSort = null; - this._tables - .updatePersonalTableViewSettings(this.connectionID, this.name, { - ordering: null, - ordering_field: null, - }) - .subscribe({ - next: () => { - console.log('Personal table view settings updated - default sort removed'); - }, - error: (error) => { - console.error('Error updating personal table view settings:', error); - }, - }); - } else { - // Set current sort as default - const direction = this.sort.active === column ? this.sort.direction : 'asc'; - this.defaultSort = { column, direction: direction as 'asc' | 'desc' }; - this._tables - .updatePersonalTableViewSettings(this.connectionID, this.name, { - ordering: this.sort.direction === 'asc' ? TableOrdering.Ascending : TableOrdering.Descending, - ordering_field: column, - }) - .subscribe({ - next: () => { - console.log('Personal table view settings updated - default sort removed'); - }, - error: (error) => { - console.error('Error updating personal table view settings:', error); - }, - }); - } - } - - isDefaultSort(column: string): boolean { - return this.defaultSort?.column === column; - } - - isForeignKey(column: string) { - return this.tableData.foreignKeysList.includes(column); - } - - getForeignKeyQueryParams(foreignKey: TableForeignKey, cell) { - return { - [foreignKey.referenced_column_name]: cell[foreignKey.referenced_column_name], - }; - } - - isWidget(column: string) { - if (this.tableData.widgetsList) return this.tableData.widgetsList.includes(column); - } - - getCellValue(foreignKey: TableForeignKey, cell) { - if (cell == null) return null; - const identityColumnName = Object.keys(cell).find((key) => key !== foreignKey.referenced_column_name); - if (identityColumnName) { - return cell[identityColumnName]; - } else { - return cell[foreignKey.referenced_column_name]; - } - } - - getFiltersCount(activeFilters: object) { - if (activeFilters && !this.hasSavedFilterActive) return Object.keys(activeFilters).length; - return 0; - } - - handleOpenFilters() { - this.openFilters.emit({ - structure: this.tableData.structure, - foreignKeysList: this.tableData.foreignKeysList, - foreignKeys: this.tableData.foreignKeys, - widgets: this.tableData.widgets, - }); - this.searchString = ''; - } - - handleOpenCreateQuickFilter() { - this.savedFiltersPanel?.handleOpenSavedFiltersDialog(); - } - - handleSearch() { - this.searchString = this.searchString.trim(); - this.staticSearchString = this.searchString; - this.search.emit(this.searchString); - } - - handleOpenExportDialog() { - this.dialog.open(DbTableExportDialogComponent, { - width: '25em', - data: { - connectionID: this.connectionID, - tableName: this.name, - sortColumn: this.sort.active, - sortOrder: this.sort.direction.toUpperCase(), - filters: this.activeFilters, - search: this.searchString, - }, - }); - } - - handleOpenImportDialog() { - this.dialog.open(DbTableImportDialogComponent, { - width: '25em', - data: { - connectionID: this.connectionID, - tableName: this.name, - isTestConnection: this.isTestConnection, - }, - }); - } - - clearSearch() { - this.searchString = null; - this.search.emit(this.searchString); - } - - getFilter(activeFilter: { key: string; value: object }) { - const displayedName = normalizeTableName(activeFilter.key); - const comparator = Object.keys(activeFilter.value)[0]; - const filterValue = Object.values(activeFilter.value)[0]; - if (comparator === 'startswith') { - return `${displayedName} = ${filterValue}...`; - } else if (comparator === 'endswith') { - return `${displayedName} = ...${filterValue}`; - } else if (comparator === 'contains') { - return `${displayedName} = ...${filterValue}...`; - } else if (comparator === 'icontains') { - return `${displayedName} != ...${filterValue}...`; - } else if (comparator === 'empty') { - return `${displayedName} = ' '`; - } else { - return `${displayedName} ${this.displayedComparators[Object.keys(activeFilter.value)[0]]} ${filterValue}`; - } - } - - /** Whether the number of selected elements matches the total number of rows. */ - isAllSelected() { - return this.tableData.rowsSubject.value.length === this.selection.selected.length; - } - - /** Selects all rows if they are not all selected; otherwise clear selection. */ - toggleAllRows() { - if (this.isAllSelected()) { - this.selection.clear(); - } else { - this.selection.select(...this.tableData.rowsSubject.value); - } - } - - /** The label for the checkbox on the passed row */ - checkboxLabel(row?: any): string { - if (!row) { - return `${this.isAllSelected() ? 'deselect' : 'select'} all`; - } - return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${row.position + 1}`; - } - - stashUrlParams() { - this._tableState.setBackUrlParams( - this.route.snapshot.queryParams.page_index, - this.route.snapshot.queryParams.page_size, - this.route.snapshot.queryParams.sort_active, - this.route.snapshot.queryParams.sort_direction, - ); - this.stashFilters(); - } - - stashFilters() { - if (this.activeFilters && Object.keys(this.activeFilters).length > 0) { - this._tableState.setBackUrlFilters(this.activeFilters); - } else { - this._tableState.setBackUrlFilters(null); - } - } - - getIdentityFieldsValues() { - if (this.tableData.identityColumn) return this.selection.selected.map((row) => row[this.tableData.identityColumn]); - return null; - } - - handleAction(e, action, element) { - e.stopPropagation(); - - this.activateActions.emit({ - action, - primaryKeys: [this.tableData.getQueryParams(element)], - ...(this.tableData.identityColumn ? { identityFieldValues: [element[this.tableData.identityColumn]] } : null), - }); - } - - handleActions(action) { - const primaryKeys = this.selection.selected.map((row) => this.tableData.getQueryParams(row)); - const identityFieldValues = this.getIdentityFieldsValues(); - - this.activateActions.emit({ - action, - primaryKeys, - identityFieldValues, - }); - } - - handleDeleteRow(e, element) { - e.stopPropagation(); - this.stashFilters(); - - this.activateActions.emit({ - action: { - title: 'Delete row', - type: 'multiple', - require_confirmation: true, - }, - primaryKeys: [this.tableData.getQueryParams(element)], - ...(this.tableData.identityColumn ? { identityFieldValues: [element[this.tableData.identityColumn]] } : null), - }); - } - - handleViewRow(row: TableRow) { - this.selectedRowType = 'record'; - this._tableState.selectRow({ - connectionID: this.connectionID, - tableName: this.name, - record: row, - columnsOrder: this.tableData.dataColumns, - primaryKeys: this.tableData.getQueryParams(row), - foreignKeys: this.tableData.foreignKeys, - foreignKeysList: this.tableData.foreignKeysList, - widgets: this.tableData.widgets, - widgetsList: this.tableData.widgetsList, - fieldsTypes: this.tableData.tableTypes, - relatedRecords: this.tableData.relatedRecords || null, - link: `/dashboard/${this.connectionID}/${this.name}/entry`, - }); - } - - handleForeignKeyView(foreignKeys, row) { - this.selectedRowType = 'foreignKey'; - - this._tableState.selectRow({ - connectionID: null, - tableName: null, - record: null, - columnsOrder: null, - primaryKeys: null, - foreignKeys: null, - foreignKeysList: null, - widgets: null, - widgetsList: null, - fieldsTypes: null, - relatedRecords: null, - link: null, - }); - - this._tableRow - .fetchTableRow(this.connectionID, foreignKeys.referenced_table_name, { - [foreignKeys.referenced_column_name]: row[foreignKeys.referenced_column_name], - }) - .subscribe((res) => { - const foreignKeysList = res.foreignKeys.map((foreignKey: TableForeignKey) => foreignKey.column_name); - const filedsTypes = getTableTypes(res.structure, foreignKeysList); - - const formattedRecord = Object.entries(res.row).reduce((acc, [key, value]) => { - acc[key] = formatFieldValue(value, filedsTypes[key]); - return acc; - }, {}); - - this._tableState.selectRow({ - connectionID: this.connectionID, - tableName: foreignKeys.referenced_table_name, - record: formattedRecord, - columnsOrder: res.list_fields, - primaryKeys: { - [foreignKeys.referenced_column_name]: res.row[foreignKeys.referenced_column_name], - }, - foreignKeys: Object.assign( - {}, - ...res.foreignKeys.map((foreignKey: TableForeignKey) => ({ [foreignKey.column_name]: foreignKey })), - ), - foreignKeysList, - widgets: Object.assign( - {}, - ...res.table_widgets.map((widget: Widget) => { - let parsedParams; - - try { - parsedParams = JSON5.parse(widget.widget_params); - } catch { - parsedParams = {}; - } - - return { - [widget.field_name]: { - ...widget, - widget_params: parsedParams, - }, - }; - }), - ), - widgetsList: res.table_widgets.map((widget) => widget.field_name), - fieldsTypes: filedsTypes, - relatedRecords: res.referenced_table_names_and_columns[0], - link: `/dashboard/${this.connectionID}/${foreignKeys.referenced_table_name}/entry`, - }); - }); - } - - handleViewAIpanel() { - this._tableState.handleViewAIpanel(); - } - - isRowSelected(primaryKeys) { - // console.log('isRowSelected', this.selectedRowType, this.selectedRow, primaryKeys); - if (this.selectedRowType === 'record' && this.selectedRow && this.selectedRow.primaryKeys !== null) - return ( - Object.keys(this.selectedRow.primaryKeys).length && - JSON.stringify(this.selectedRow.primaryKeys) === JSON.stringify(primaryKeys) - ); - return false; - } - - isForeignKeySelected(record, foreignKey: TableForeignKey) { - if (record == null) return false; - const primaryKeyValue = record[foreignKey.referenced_column_name]; - - if (this.selectedRowType === 'foreignKey' && this.selectedRow && this.selectedRow.record !== null) { - return ( - Object.values(this.selectedRow.primaryKeys)[0] === primaryKeyValue && - this.selectedRow.tableName === foreignKey.referenced_table_name - ); - } - return false; - } - - showCopyNotification = (message: string) => { - this._notifications.showSuccessSnackbar(message); - }; - - switchTable(tableName: string) { - if (tableName && tableName !== this.name) { - this.router.navigate([`/dashboard/${this.connectionID}/${tableName}`], { - queryParams: { page_index: 0, page_size: 30 }, - }); - } - } - - getFolderTables(folder: TableCategory): TableProperties[] { - return folder.tables || []; - } - - onFilterSelected($event) { - console.log('table view fiers filterSelected:', $event); - this.applyFilter.emit($event); - } - - get sortedColumns() { - if (!this.tableData || !this.tableData.columns) { - return []; - } - // Sort columns: visible (selected=true) first, then hidden (selected=false) - return [...this.tableData.columns].sort((a, b) => { - if (a.selected === b.selected) return 0; - return a.selected ? -1 : 1; - }); - } - - onColumnVisibilityChange() { - this.tableData.changleColumnList(this.connectionID, this.name); - this.cdr.detectChanges(); - - // Send selected columns first, then non-selected at the end - const orderedColumns = [ - ...this.tableData.columns.filter((col) => col.selected), - ...this.tableData.columns.filter((col) => !col.selected), - ]; - - this._tables - .updatePersonalTableViewSettings(this.connectionID, this.name, { - columns_view: this.tableData.displayedDataColumns, - list_fields: orderedColumns.map((col) => col.title), - }) - .subscribe({ - next: () => { - console.log('Personal table view settings updated with custom ordering'); - }, - error: (error) => { - console.error('Error updating personal table view settings:', error); - }, - }); - } - - handleActiveFilterClick(filterKey: string) { - const dialogRef = this.dialog.open(DbTableFiltersDialogComponent, { - width: '56em', - panelClass: 'mobile-bottom-sheet-dialog', - data: { - connectionID: this.connectionID, - tableName: this.name, - displayTableName: this.displayName, - structure: { - structure: this.tableData.structure, - foreignKeys: this.tableData.foreignKeys, - foreignKeysList: this.tableData.foreignKeysList, - widgets: this.tableData.widgets || [], - }, - autofocusField: filterKey, - }, - }); - - dialogRef.afterClosed().subscribe((action) => { - if (action === 'filter' && dialogRef.componentInstance) { - const filtersFromDialog = { ...dialogRef.componentInstance.tableRowFieldsShown }; - const comparators = dialogRef.componentInstance.tableRowFieldsComparator; - this.openFilters.emit({ - structure: this.tableData.structure, - foreignKeysList: this.tableData.foreignKeysList, - foreignKeys: this.tableData.foreignKeys, - widgets: this.tableData.widgets, - filters: filtersFromDialog, - comparators: comparators, - }); - } - }); - } - - onColumnsMenuDrop(event: CdkDragDrop) { - if (event.previousIndex === event.currentIndex) { - return; - } - - // The drag indices are based on sortedColumns (visible first, then hidden) - // We need to map these to the actual indices in this.tableData.columns - const sorted = this.sortedColumns; - const draggedColumn = sorted[event.previousIndex]; - const targetColumn = sorted[event.currentIndex]; - - // Find actual indices in the original columns array - const actualPreviousIndex = this.tableData.columns.findIndex((col) => col.title === draggedColumn.title); - const actualCurrentIndex = this.tableData.columns.findIndex((col) => col.title === targetColumn.title); - - if (actualPreviousIndex === -1 || actualCurrentIndex === -1) { - return; - } - - // Reorder columns array in the menu - moveItemInArray(this.tableData.columns, actualPreviousIndex, actualCurrentIndex); - - // Update dataColumns array - this.tableData.dataColumns = this.tableData.columns.map((column) => column.title); - - // Update displayedDataColumns to match the new order (only visible columns) - const newDisplayedOrder = this.tableData.columns.filter((col) => col.selected).map((col) => col.title); - - this.tableData.displayedDataColumns = newDisplayedOrder; - - // Update full displayed columns list - THIS UPDATES THE TABLE IMMEDIATELY - if (this.tableData.keyAttributes && this.tableData.keyAttributes.length) { - this.tableData.displayedColumns = ['select', ...newDisplayedOrder, 'actions']; - } else { - this.tableData.displayedColumns = [...newDisplayedOrder]; - } - - // Force Angular to detect changes and re-render the table immediately - this.cdr.detectChanges(); - - // Send selected columns first, then non-selected at the end - const orderedColumns = [ - ...this.tableData.columns.filter((col) => col.selected), - ...this.tableData.columns.filter((col) => !col.selected), - ]; - - this._tables - .updatePersonalTableViewSettings(this.connectionID, this.name, { - list_fields: orderedColumns.map((col) => col.title), - }) - .subscribe({ - next: () => { - console.log('Personal table view settings updated with custom ordering'); - }, - error: (error) => { - console.error('Error updating personal table view settings:', error); - }, - }); - - console.log('Columns reordered in menu - table updated:', newDisplayedOrder); - } - - exportData() { - const convertToCSVValue = (value: any): string => { - // Handle null and undefined - if (value === null || value === undefined) { - return ''; - } - - // Handle nested objects and arrays - convert to JSON string - if (typeof value === 'object') { - try { - value = JSON.stringify(value); - } catch (_e) { - value = '[Object]'; - } - } - - // Convert to string if not already - const stringValue = String(value); - - // Check if value needs to be quoted (contains comma, double quote, or newline) - if ( - stringValue.includes(',') || - stringValue.includes('"') || - stringValue.includes('\n') || - stringValue.includes('\r') - ) { - // Escape double quotes by doubling them and wrap in quotes - return `"${stringValue.replace(/"/g, '""')}"`; - } - - return stringValue; - }; - - // Check if there's any selection - if (!this.selection.selected || this.selection.selected.length === 0) { - this._notifications.showErrorSnackbar('No rows selected for export'); - return; - } - - // Use the displayed columns order from the table - const columnsToExport = this.tableData.displayedDataColumns; - - // Create CSV rows with proper handling of foreign keys - const csv = this.selection.selected.map((row) => - columnsToExport - .map((fieldName) => { - let value = row[fieldName]; - - if (this.isForeignKey(fieldName) && value && typeof value === 'object') { - const foreignKey = this.tableData.foreignKeys[fieldName]; - if (foreignKey) { - value = value[foreignKey.referenced_column_name]; - } - } - - return convertToCSVValue(value); - }) - .join(','), - ); - - // Add header row using the same column order - csv.unshift(columnsToExport.map((h) => convertToCSVValue(h)).join(',')); - const csvArray = csv.join('\r\n'); - - const a = document.createElement('a'); - const blob = new Blob([csvArray], { type: 'text/csv' }); - const url = window.URL.createObjectURL(blob); - - a.href = url; - a.download = 'myFile.csv'; - a.click(); - window.URL.revokeObjectURL(url); - a.remove(); - } - - private _tablePermission(action: string) { - return computed(() => { - const rid = this._tableResourceId(); - return rid ? this._permissions.canI(action, 'Table', rid)() : null; - }); - } - - private _matchesTableQuery(table: TableProperties, query: string): boolean { - const haystack = [ - (table as any).normalizedTableName, - table.display_name, - table.table, - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); - return haystack.includes(query); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.css deleted file mode 100644 index 849c191f9..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.css +++ /dev/null @@ -1,229 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.wrapper { - min-width: 300px; - margin: 0 auto 16px; - width: 90%; -} - -.header { - display: flex; - justify-content: space-between; - margin: 2.5em 70px 24px 0; -} - -.header-actions-box { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 16px; -} - -.header-actions { - display: flex; - gap: 16px; -} - -.header-links { - display: flex; - gap: 16px; -} - -.mobile-header { - display: none; -} - -.widget-settings { - display: grid; - grid-template-columns: minmax(10%, 130px) 1fr 2fr 1fr 2fr 50px; - align-items: start; - grid-gap: 0 20px; -} - -.widget-item { - display: contents; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--background-color); - box-shadow: var(--shadow); - height: 64px; - /* padding: 0 max(calc(50vw - 500px), 2%); */ - padding: 0 calc(5% + 70px) 0 5%; - width: 100vw; - z-index: 10; -} - -@media (prefers-color-scheme: dark) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.5), 0 2px 2px 0 rgba(0, 0, 0, 0.64), 0 1px 5px 0 rgba(0, 0, 0, 0.85); - --background-color: var(--surface-dark-color); - } -} - -@media (prefers-color-scheme: light) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); - --background-color: #fff; - } -} - -@media (width <= 600px) { - .wrapper { - width: 100%; - margin: 0 auto 88px; - padding: 0 16px; - min-width: 0; - } - - .row-breadcrumbs { - display: none !important; - } - - .mobile-header { - display: flex; - align-items: center; - gap: 8px; - margin: 20px 0 12px -12px; - } - - .mobile-header__back { - margin-right: 0; - } - - .mobile-header__titles { - display: flex; - flex-direction: column; - min-width: 0; - } - - .mobile-header__title { - margin: 0 !important; - padding: 0 !important; - font-size: 18px !important; - font-weight: 600 !important; - line-height: 18px !important; - } - - .mobile-header__subtitle { - display: block; - margin: 4px 0 0 !important; - padding: 0 !important; - font-size: 12px; - line-height: 14px; - color: rgba(0, 0, 0, 0.54); - font-weight: 400; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .actions__back { - display: none !important; - } - - .actions { - justify-content: flex-end; - } - - .header { - flex-direction: column; - gap: 12px; - margin: 16px 0 16px; - } - - .header-actions-box { - width: 100%; - align-items: stretch; - gap: 8px; - } - - .header-actions { - flex-wrap: wrap; - gap: 8px; - width: 100%; - } - - .header-actions > button { - flex: 1 1 auto; - } - - .header-links { - flex-direction: column; - align-items: flex-start; - gap: 0; - } - - .widget-settings { - display: flex; - flex-direction: column; - gap: 16px; - } - - .widget-item { - display: block; - position: relative; - background: var(--mat-table-background-color, #fff); - border-radius: 12px; - padding: 16px 16px 8px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); - } - - .widget-item ::ng-deep .mat-mdc-form-field { - width: 100%; - } - - .widget-item ::ng-deep .widget-field-name { - display: block; - font-weight: 600; - margin: 0 0 12px 0; - font-size: 14px; - } - - .widget-item ::ng-deep .widget-delete-button { - display: none !important; - } - - .widget-item ::ng-deep .code-editor-box { - min-height: 140px; - margin-bottom: 16px; - } - - .actions { - padding: 0 16px; - justify-content: space-between; - gap: 8px; - } - - .actions > a, - .actions > button { - flex: 1 1 auto; - } - - .actions_empty-state { - display: none !important; - } - - .wrapper:has(app-widgets-empty-state) { - margin-bottom: 80px; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .widget-item { - background: var(--surface-dark-color); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2); - } - - .mobile-header__subtitle { - color: rgba(255, 255, 255, 0.6); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.html deleted file mode 100644 index 63a58aaa7..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.html +++ /dev/null @@ -1,93 +0,0 @@ - - - -
-
-
- - arrow_back - -
-

Widgets

- {{ tableName }} -
-
- - - - -
- -
- - - - -
- - Back - - - -
-
- - - - - - -
\ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.spec.ts deleted file mode 100644 index 26e21be4b..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { CodeEditComponent } from '../../../ui-components/record-edit-fields/code/code.component'; -import { MarkdownEditComponent } from '../../../ui-components/record-edit-fields/markdown/markdown.component'; -import { DashboardComponent } from '../../dashboard.component'; -import { DbTableWidgetsComponent } from './db-table-widgets.component'; -import { WidgetComponent } from './widget/widget.component'; -import { WidgetDeleteDialogComponent } from './widget-delete-dialog/widget-delete-dialog.component'; - -describe('DbTableWidgetsComponent', () => { - let component: DbTableWidgetsComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let connectionsService: ConnectionsService; - let mockMatDialog: { open: ReturnType }; - const dialogRefSpyObj = { - afterClosed: vi.fn().mockReturnValue(of('delete')), - close: vi.fn(), - componentInstance: { deleteWidget: of('user_name') }, - }; - - const fakeFirstName = { - column_name: 'FirstName', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }; - const fakeId = { - column_name: 'Id', - column_default: 'auto_increment', - data_type: 'int', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }; - const fakeBool = { - column_name: 'bool', - column_default: null, - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const mockTableStructure = { - structure: [fakeFirstName, fakeId, fakeBool], - primaryColumns: [ - { - data_type: 'int', - column_name: 'Id', - }, - ], - foreignKeys: [ - { - referenced_column_name: 'CustomerId', - referenced_table_name: 'Customers', - constraint_name: 'Orders_ibfk_2', - column_name: 'Id', - }, - ], - readonly_fields: [], - table_widgets: [], - }; - - const tableWidgetsNetwork = [ - { - id: 'a57e0c7f-a348-4aae-9ec4-fdbec0c0d0b6', - field_name: 'email', - widget_type: 'Textarea', - widget_params: '', - name: 'user email', - description: '', - }, - ]; - - beforeEach(async () => { - mockMatDialog = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule.withRoutes([ - { path: 'dashboard/:connection-id/:table-name', component: DashboardComponent }, - ]), - MatSnackBarModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - DbTableWidgetsComponent, - ], - providers: [provideHttpClient(), { provide: MatDialogRef, useValue: {} }], - }) - .overrideComponent(DbTableWidgetsComponent, { - set: { - providers: [{ provide: MatDialog, useFactory: () => mockMatDialog }], - }, - }) - .overrideComponent(WidgetComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .overrideComponent(CodeEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .overrideComponent(MarkdownEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableWidgetsComponent); - component = fixture.componentInstance; - tablesService = TestBed.inject(TablesService); - connectionsService = TestBed.inject(ConnectionsService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set connection id and table name', () => { - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('12345678'); - vi.spyOn(tablesService, 'currentTableName', 'get').mockReturnValue('Users'); - vi.spyOn(tablesService, 'fetchTableStructure').mockReturnValue(of(mockTableStructure)); - vi.spyOn(tablesService, 'fetchTableWidgets').mockReturnValue(of(tableWidgetsNetwork)); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.connectionID).toEqual('12345678'); - expect(component.tableName).toEqual('Users'); - expect(component.fields).toEqual(['FirstName', 'Id', 'bool']); - expect(component.widgets).toEqual(tableWidgetsNetwork); - }); - - it('should add new empty widget to widgets array', () => { - component.widgets = [ - { - field_name: 'user_id', - widget_type: 'textarea', - widget_params: '', - name: '', - description: '', - }, - ]; - - component.addNewWidget(); - - expect(component.widgets).toEqual([ - { - field_name: 'user_id', - widget_type: 'textarea', - widget_params: '', - name: '', - description: '', - }, - { - field_name: '', - widget_type: 'Default', - widget_params: '// No settings required', - name: '', - description: '', - }, - ]); - }); - - it('should exclude field from lields list when it is added the list of widgets', () => { - component.fields = ['user_id', 'first_name', 'last_name', 'email']; - component.selectWidgetField('first_name'); - - expect(component.fields).toEqual(['user_id', 'last_name', 'email']); - }); - - it('should open dialog to confirm deletion of widget', () => { - component.fields = ['user_age']; - component.widgets = [ - { - field_name: 'user_id', - widget_type: 'textarea', - widget_params: '', - name: '', - description: '', - }, - { - field_name: 'user_name', - widget_type: 'Default', - widget_params: '', - name: 'name', - description: '', - }, - ]; - - mockMatDialog.open.mockReturnValue(dialogRefSpyObj as any); - component.openDeleteWidgetDialog('user_name'); - - expect(mockMatDialog.open).toHaveBeenCalledWith(WidgetDeleteDialogComponent, { - width: '25em', - data: 'user_name', - }); - - expect(component.fields).toEqual(['user_age', 'user_name']); - expect(component.widgets).toEqual([ - { - field_name: 'user_id', - widget_type: 'textarea', - widget_params: '', - name: '', - description: '', - }, - ]); - }); - - it('should update widgets', () => { - component.connectionID = '12345678'; - component.tableName = 'users'; - component.widgets = [ - { - field_name: 'email', - widget_type: 'Textarea', - widget_params: '', - name: 'user email', - description: '', - }, - ]; - const fakeUpdateTableWidgets = vi.spyOn(tablesService, 'updateTableWidgets').mockReturnValue(of()); - // const fakeFatchWidgets = vi.spyOn(tablesService, 'fetchTableWidgets').mockReturnValue(of(tableWidgetsNetwork)); - - component.updateWidgets(); - - expect(fakeUpdateTableWidgets).toHaveBeenCalledWith('12345678', 'users', [ - { - field_name: 'email', - widget_type: 'Textarea', - widget_params: '', - name: 'user email', - description: '', - }, - ]); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts deleted file mode 100644 index 72022128d..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/db-table-widgets.component.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { CommonModule, Location } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Title } from '@angular/platform-browser'; -import { Router, RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { difference } from 'lodash-es'; -import posthog from 'posthog-js'; -import { UIwidgets } from 'src/app/consts/record-edit-types'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TableField, Widget } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { PlaceholderTableWidgetsComponent } from '../../../skeletons/placeholder-table-widgets/placeholder-table-widgets.component'; -import { AlertComponent } from '../../../ui-components/alert/alert.component'; -import { BreadcrumbsComponent } from '../../../ui-components/breadcrumbs/breadcrumbs.component'; -import { WidgetComponent } from './widget/widget.component'; -import { WidgetDeleteDialogComponent } from './widget-delete-dialog/widget-delete-dialog.component'; -import { WidgetsEmptyStateComponent } from './widgets-empty-state/widgets-empty-state.component'; - -@Component({ - selector: 'app-db-table-widgets', - templateUrl: './db-table-widgets.component.html', - styleUrls: ['./db-table-widgets.component.css'], - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatDialogModule, - MatIconModule, - MatInputModule, - MatSelectModule, - RouterModule, - AlertComponent, - PlaceholderTableWidgetsComponent, - BreadcrumbsComponent, - WidgetComponent, - WidgetsEmptyStateComponent, - Angulartics2OnModule, - ], -}) -export class DbTableWidgetsComponent implements OnInit { - protected posthog = posthog; - public connectionID: string | null = null; - public tableName: string | null = null; - public dispalyTableName: string; - public fields: string[] = []; - public fieldsCount: number; - public widgets: Widget[] = null; - public widgetTypes = Object.keys(UIwidgets).sort(); - public submitting: boolean = false; - public widgetsWithSettings: string[]; - public codeEditorTheme: 'vs' | 'vs-dark' = 'vs-dark'; - - public paramsEditorOptions = { - minimap: { enabled: false }, - lineNumbersMinChars: 3, - folding: false, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - // JSON5-formatted default params - public defaultParams = { - Binary: `// Configure binary display/edit encoding. -// Supported: "hex" (default), "base64", "ascii" -// example: -{ - "encoding": "hex" -} -`, - Boolean: `// Display "Yes/No" buttons with configurable options: -// - allow_null: Use "false" to require selection, "true" if field can be left unspecified -// - invert_colors: Swap the color scheme (typically green=Yes, red=No becomes red=Yes, green=No) - -{ - "allow_null": false, - "invert_colors": false -} -`, - Code: `// provide language of code to highlight: 'html', 'css', 'typescript', 'yaml', 'markdown' -// example: -{ - "language": "html" -} -`, - Color: `// Optional: Specify output format for color values -// Supported formats: "hex", "hex_hash" (default), "rgb", "hsl" -// Example configuration: - -{ - "format": "hex_hash" // Will display colors as "#FF5733" -} - -// Format options: -// - "hex": Display as "FF5733" (no hash) -// - "hex_hash": Display as "#FF5733" (default) -// - "rgb": Display as "rgb(255, 87, 51)" -// - "hsl": Display as "hsl(9, 100%, 60%)"`, - Country: `// Configure country display options -// Example: -{ - "show_flag": true, - "allow_null": false -} -`, - Date: `// Configure date display options -// formatDistanceWithinHours: Shows relative time (e.g., "2 hours ago") for dates within the specified hours -// Default: 0 hours. Set to 0 to disable relative time display -{ - "formatDistanceWithinHours": 0 -}`, - DateTime: `// Configure datetime display options -// formatDistanceWithinHours: Shows relative time (e.g., "2 hours ago") for dates within the specified hours -// Default: 0 hours. Set to 0 to disable relative time display -{ - "formatDistanceWithinHours": 0 -}`, - Default: `// No settings required`, - File: `// provide type of file: 'hex', 'base64' or 'file' -// example: -{ - "type": "hex" -} -`, - Foreign_key: `// Provide settings for foreign key widget -{ - "column_name": "", // copy the name of the column you selected - "referenced_column_name": "", - "referenced_table_name": "" -} -`, - Image: `// provide image height in px to dispaly in table -// prefix: optional URL prefix to prepend to image source -// example: -{ - "height": 100, - "prefix": "https://example.com/images/" -} -`, - JSON: `// No settings required`, - Language: `// Configure language display options -// show_flag: Display country flag emoji next to language name -// Example: -{ - "show_flag": true -}`, - Markdown: `// No settings required`, - Money: `// Configure money widget settings -// cents: when true, stored values are integer minor units (e.g. 1099 = $10.99), -// matching Stripe. For zero-decimal currencies (JPY, KRW, VND, ...) values are -// shown as-is. Do NOT enable on a column that already stores decimal amounts. -// example: -{ - "default_currency": "USD", - "decimal_places": 2, - "allow_negative": true, - "cents": false -} -`, - Number: `// Configure number display with unit conversion and threshold validation -// Example units: "bytes", "meters", "seconds", "grams" -// threshold_min/threshold_max: Values outside these limits will be highlighted -{ - "unit": null, - "threshold_min": null, - "threshold_max": null -}`, - Password: `// provide algorithm to encrypt your password, one of: -//sha1, sha3, sha224, sha256, sha512, sha384, bcrypt, scrypt, argon2, pbkdf2. -// example: - -{ - "encrypt": true, - "algorithm": "sha256" -} - -`, - Phone: `// Configure international phone number widget -// example: -{ - "preferred_countries": ["US", "GB", "CA"], - "enable_placeholder": true, - "phone_validation": true -} -`, - Range: `// Configure the minimum, maximum and step values for the range -// Default: min = 0, max = 100, step = 1 -{ - "min": 0, - "max": 100, - "step": 1 -} -`, - Readonly: `// No settings required`, - Select: `// provide array of options to map database value (key 'value') in human readable value (key 'label'); -// for example: -// AK => Alaska, -// CA => California - -{ - "allow_null": true, - "options": [ - { - "value": "UA", - "label": "🇺🇦 Ukraine", - "background_color": "gold" - }, - { - "value": "PL", - "label": "🇵🇱 Poland", - "background_color": "#FF1212" - }, - { - "value": "US", - "label": "🇺🇸 United States", - "background_color": "rgba(100, 150, 255, 0.5)" - } - ] -}`, - String: `// Optional validation for string values -// validate: Any validator.js method (e.g., "isEmail", "isURL", "isUUID", "isJSON", "isAlpha", "isNumeric") -// Full list: isEmail, isURL, isMACAddress, isIP, isIPRange, isFQDN, isBoolean, isIBAN, isBIC, -// isAlpha, isAlphanumeric, isNumeric, isPort, isLowercase, isUppercase, isAscii, isBase64, -// isHexadecimal, isHexColor, isRgbColor, isHSL, isMD5, isHash, isJWT, isJSON, isUUID, -// isMongoId, isCreditCard, isISBN, isISSN, isMobilePhone, isPostalCode, isEthereumAddress, -// isCurrency, isBtcAddress, isISO8601, isISO31661Alpha2, isISO31661Alpha3, isISO4217, -// isDataURI, isMagnetURI, isMimeType, isLatLong, isSlug, isStrongPassword, isTaxID, isVAT -// OR use "regex" with a regex parameter for custom pattern matching -// force_send_empty_string: when true, always send "" to the backend on clear, -// even if the column is nullable. Default false (cleared input becomes NULL on nullable columns). -{ - "validate": null, - "regex": null, - "force_send_empty_string": false -}`, - Textarea: `// provide number of strings to show. -{ - "rows": 5 -}`, - Time: `// No settings required`, - Timezone: `// Configure timezone widget options -// Uses Intl API to populate timezone list automatically -// allow_null: Allow empty/null value selection -{ - "allow_null": false -} -`, - URL: `// prefix: optional URL prefix to prepend to the href -// example: -{ - "prefix": "https://example.com/" -} -`, - UUID: `// Configure UUID generation version and parameters -// Available versions: "v1", "v3", "v4" (default), "v5", "v7" -// For v3/v5: provide namespace and optionally name -{ - "version": "v4", - "namespace": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", - "name": "" -} -`, - Email: `// No settings required`, - S3: `// Configure S3-compatible bucket widget for file storage -// provider: Cloud provider. Supported values: -// "aws" - AWS S3 (default) -// "digitalocean" - DigitalOcean Spaces -// "backblaze" - Backblaze B2 -// "wasabi" - Wasabi -// "cloudflare-r2" - Cloudflare R2 (requires "account_id") -// bucket: bucket name (required) -// prefix: Optional path prefix for uploaded files -// region: Region for the bucket (default: us-east-1; for cloudflare-r2 use "auto") -// account_id: Cloudflare account ID. Required when provider is "cloudflare-r2". -// type: "file" (default) - accepts all file types, "image" - accepts only images -// access_key_id: Unique identifier of the secret containing the Access Key ID -// access_key: Unique identifier of the secret containing the Secret Access Key -// -// ⚠️ IMPORTANT: DO NOT INCLUDE BUCKET SECRETS DIRECTLY IN WIDGET SETTINGS! -// Store your credentials as secrets in Rocketadmin and reference them here by their unique identifiers to ensure security and prevent exposure of sensitive information. - -{ - "provider": "aws", - "bucket": "your-bucket-name", - "prefix": "uploads/", - "region": "us-east-1", - "type": "file", - "access_key_id": "COMPANY-SECRET/bucket-access-key-id", - "access_key": "COMPANY-SECRET/bucket-access-key" -} -`, - }; - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private _location: Location, - private _uiSettings: UiSettingsService, - public dialog: MatDialog, - public router: Router, - private title: Title, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this.tableName = this._tables.currentTableName; - this.widgetsWithSettings = Object.entries(this.defaultParams) - .filter(([_key, value]) => value !== '// No settings required') - .map((widgetDefault) => widgetDefault[0]); - - this._tables.fetchTableStructure(this.connectionID, this.tableName).subscribe((res) => { - this.fieldsCount = res.structure.length; - this.fields = res.structure.map((field: TableField) => field.column_name); - this.dispalyTableName = res.display_name || normalizeTableName(this.tableName); - this.title.setTitle(`${this.dispalyTableName} - Field display | Rocketadmin`); - this.getWidgets(); - }); - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } - - get currentConnection() { - return this._connections.currentConnection; - } - - getCrumbs(name: string) { - return [ - { - label: name, - link: `/dashboard/${this.connectionID}`, - }, - { - label: this.dispalyTableName, - link: `/dashboard/${this.connectionID}/${this.tableName}`, - }, - { - label: 'UI Widgets', - link: null, - }, - ]; - } - - goBack() { - this._location.back(); - } - - addNewWidget() { - this.widgets.push({ - field_name: '', - widget_type: 'Default', - widget_params: '// No settings required', - name: '', - description: '', - }); - } - - selectWidgetField(column_name: string) { - this.fields.splice(this.fields.indexOf(column_name), 1); - } - - widgetTypeChange(fieldName) { - let currentWidget = this.widgets.find((widget) => widget.field_name === fieldName); - currentWidget.widget_params = this.defaultParams[currentWidget.widget_type || 'Default']; - - this.widgetParamsChange({ fieldName: currentWidget.field_name, value: currentWidget.widget_params }); - } - - isReadOnly(type: string) { - return !this.widgetsWithSettings.includes(type); - } - - widgetParamsChange(e: { fieldName: string; value: any }) { - let currentWidget = this.widgets.find((widget) => widget.field_name === e.fieldName); - currentWidget.widget_params = e.value; - } - - openDeleteWidgetDialog(widgetFieldName: string) { - const dialogRef = this.dialog.open(WidgetDeleteDialogComponent, { - width: '25em', - data: widgetFieldName, - }); - - dialogRef.afterClosed().subscribe((action) => { - if (action === 'delete') { - this.fields.push(widgetFieldName); - this.widgets = this.widgets.filter((widget) => widget.field_name !== widgetFieldName); - this.updateWidgets(true); - } - }); - } - - openClearAllConfirmation() { - const dialogRef = this.dialog.open(WidgetDeleteDialogComponent, { - width: '25em', - }); - - dialogRef.afterClosed().subscribe((action) => { - if (action === 'delete') { - const widgetsToDelete = this.widgets.map((widget) => widget.field_name); - this.fields = [...this.fields, ...widgetsToDelete]; - this.widgets = []; - this.updateWidgets(true); - } - }); - } - - getWidgets() { - this._tables.fetchTableWidgets(this.connectionID, this.tableName).subscribe((res) => { - const currentWidgetTypes = res.map((widget: Widget) => widget.field_name); - this.fields = difference(this.fields, currentWidgetTypes); - res.forEach((widget: Widget) => { - if (widget.widget_type === '') widget.widget_type = 'Default'; - }); - this.widgets = res; - }); - } - - updateWidgets(afterDeleteAll?: boolean) { - this.submitting = true; - - this.widgets.forEach((widget) => { - if (widget.widget_type === 'Default') widget.widget_type = ''; - }); - - this._tables.updateTableWidgets(this.connectionID, this.tableName, this.widgets).subscribe( - () => { - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Widgets: widgets are updated successfully', - }); - posthog.capture('Widgets: widgets are updated successfully'); - if (!afterDeleteAll) this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}`]); - }, - undefined, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.html deleted file mode 100644 index 017a28661..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.html +++ /dev/null @@ -1,15 +0,0 @@ -

Confirm widget delete

- -

- You are going to delete widget for {{ widgetFieldName }} field. -

- -

Do you want to delete all widgets?

-
-
-

Please confirm.

-
- - - - diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.spec.ts deleted file mode 100644 index b1ba868e1..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; - -import { WidgetDeleteDialogComponent } from './widget-delete-dialog.component'; - -describe('WidgetDeleteDialogComponent', () => { - let component: WidgetDeleteDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, WidgetDeleteDialogComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: {} }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(WidgetDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.ts deleted file mode 100644 index cc4afe66b..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget-delete-dialog/widget-delete-dialog.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; - -@Component({ - selector: 'app-widget-delete-dialog', - templateUrl: './widget-delete-dialog.component.html', - styleUrls: ['./widget-delete-dialog.component.css'], - imports: [CommonModule, MatButtonModule, MatIconModule, MatDialogModule], -}) -export class WidgetDeleteDialogComponent implements OnInit { - constructor( - @Inject(MAT_DIALOG_DATA) public widgetFieldName: any, - _dialogRef: MatDialogRef, - ) {} - - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.css deleted file mode 100644 index a163e493c..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.css +++ /dev/null @@ -1,47 +0,0 @@ -.widget-field-name { - align-self: baseline; - margin-top: 8px; - word-break: break-all; -} - -.widget-type ::ng-deep .mat-mdc-form-field-hint-wrapper { - --mat-button-outlined-container-height: 24px; - --mat-button-outlined-horizontal-padding: 12px; - - padding: 8px 4px; -} - -.code-editor-box { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 4px; - margin-bottom: 20px; - overflow-y: auto; - resize: vertical; -} - -.code-editor-box ::ng-deep .ngs-code-editor { - height: 100%; -} - -.widget-params__label { - position: absolute; - top: 8px; - right: 8px; - font-size: 12px; - font-weight: 600; -} - -.widget-params__shutter { - position: absolute; - top: 0; - left: 0; - background: rgba(255, 255, 255, 0.5); - cursor: not-allowed; - height: 100%; - width: 100%; -} - -.widget-delete-button { - margin-top: 4px; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.html deleted file mode 100644 index 346386a5f..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.html +++ /dev/null @@ -1,59 +0,0 @@ - - {{ widget.field_name }} - - - - Field - - {{ field }} - - - - -
- - Displayed field type - - {{ widgetType }} - - - - Docs - open_in_new - - - - -
- -
- - -
- - - Displayed field name - - - - - Displayed field description - - - - \ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.spec.ts deleted file mode 100644 index 611199779..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { WidgetComponent } from './widget.component'; - -describe('WidgetComponent', () => { - let component: WidgetComponent; - let fixture: ComponentFixture; - - const mockWidget = { - field_name: 'password', - widget_type: 'Password', - widget_params: '{"minLength": 8}', - name: 'User Password', - description: 'Password field', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [WidgetComponent, BrowserAnimationsModule], - }) - .overrideComponent(WidgetComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(WidgetComponent); - component = fixture.componentInstance; - - component.widget = { ...mockWidget }; - component.index = 0; - component.fields = ['email', 'username', 'password']; - component.widgetTypes = ['Default', 'Password', 'Textarea', 'Code']; - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize mutableWidgetParams on init', () => { - expect(component.mutableWidgetParams).toEqual({ - language: 'json', - uri: 'widget-params-0.json', - value: '{"minLength": 8}', - }); - }); - - it('should have correct code editor options', () => { - expect(component.paramsEditorOptions.minimap.enabled).toBe(false); - expect(component.paramsEditorOptions.wordWrap).toBe('on'); - expect(component.paramsEditorOptions.automaticLayout).toBe(true); - }); - - it('should have documentation URLs for widget types', () => { - expect(component.docsUrls.Password).toContain('docs.rocketadmin.com'); - expect(component.docsUrls.Boolean).toContain('widgets_management#boolean'); - expect(component.docsUrls.Code).toContain('widgets_management#code'); - }); - - it('should emit onSelectWidgetField when field is selected', () => { - const emitSpy = vi.spyOn(component.onSelectWidgetField, 'emit'); - component.onSelectWidgetField.emit('email'); - expect(emitSpy).toHaveBeenCalledWith('email'); - }); - - it('should emit onWidgetTypeChange when widget type changes', () => { - const emitSpy = vi.spyOn(component.onWidgetTypeChange, 'emit'); - component.onWidgetTypeChange.emit('Textarea'); - expect(emitSpy).toHaveBeenCalledWith('Textarea'); - }); - - it('should emit onWidgetDelete when delete is triggered', () => { - const emitSpy = vi.spyOn(component.onWidgetDelete, 'emit'); - component.onWidgetDelete.emit('password'); - expect(emitSpy).toHaveBeenCalledWith('password'); - }); - - it('should emit onWidgetParamsChange with value and fieldName', () => { - const emitSpy = vi.spyOn(component.onWidgetParamsChange, 'emit'); - component.onWidgetParamsChange.emit({ value: '{"new": "params"}', fieldName: 'password' }); - expect(emitSpy).toHaveBeenCalledWith({ value: '{"new": "params"}', fieldName: 'password' }); - }); - - it('should update mutableWidgetParams when widgetType changes', () => { - const newParams = '{"newParam": true}'; - component.widget = { ...mockWidget, widget_params: newParams }; - - component.ngOnChanges({ - widgetType: new SimpleChange('Password', 'Textarea', false), - }); - - expect(component.mutableWidgetParams.value).toBe(newParams); - }); - - it('should not update mutableWidgetParams when widgetType does not change', () => { - const originalValue = component.mutableWidgetParams.value; - - component.ngOnChanges({ - someOtherProperty: new SimpleChange('old', 'new', false), - }); - - expect(component.mutableWidgetParams.value).toBe(originalValue); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts deleted file mode 100644 index 1607d8089..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widget/widget.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - CUSTOM_ELEMENTS_SCHEMA, - EventEmitter, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Widget } from 'src/app/models/table'; - -@Component({ - selector: 'app-widget', - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatIconModule, - MatButtonModule, - CodeEditorModule, - ], - templateUrl: './widget.component.html', - styleUrl: './widget.component.css', - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class WidgetComponent implements OnInit, OnChanges { - @Input() index: number; - @Input() widget: Widget; - @Input() widgetType: string; - @Input() fields: string[]; - @Input() widgetTypes: string[]; - @Input() isReadonlyParams: boolean; - @Input() codeEditorTheme: 'vs' | 'vs-dark'; - - @Output() onSelectWidgetField = new EventEmitter(); - @Output() onWidgetTypeChange = new EventEmitter(); - @Output() onWidgetParamsChange = new EventEmitter<{ value: any; fieldName: string }>(); - @Output() onWidgetDelete = new EventEmitter(); - - public mutableWidgetParams: { - language: string; - uri: string; - value: any; - } = { - language: 'json', - uri: '', - value: {}, - }; - - public paramsEditorOptions = { - minimap: { enabled: false }, - lineNumbersMinChars: 3, - folding: false, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - - public docsUrls = { - Binary: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#binary', - Boolean: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#boolean', - Code: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#code', - Color: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#color', - Country: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#country', - Foreign_key: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#foreign-key', - Image: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#image', - Money: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#money', - Number: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#number', - Password: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#password', - Phone: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#phone', - Range: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#range', - Select: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#select', - String: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#string', - Textarea: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#textarea', - URL: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#url', - UUID: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#uuid', - Date: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', - DateTime: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', - Time: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#date-time-and-datetime', - S3: 'https://docs.rocketadmin.com/Reference/UI%20Widgets/widgets_management#s3', - }; - - ngOnInit(): void { - this.mutableWidgetParams = { - language: 'json', - uri: `widget-params-${this.index}.json`, - value: this.widget.widget_params, - }; - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes.widgetType?.currentValue) { - this.mutableWidgetParams = { - ...this.mutableWidgetParams, - value: this.widget.widget_params, - }; - console.log(this.mutableWidgetParams); - } - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.css deleted file mode 100644 index cf19415af..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.css +++ /dev/null @@ -1,390 +0,0 @@ -.empty-state { - display: flex; - align-items: stretch; - height: calc(100vh - 200px - 16px); - gap: 0; - max-width: 1200px; - margin: 0 auto; -} - -.empty-left { - width: 420px; - flex-shrink: 0; - display: flex; - flex-direction: column; - justify-content: center; - padding: 32px 32px 32px 0; -} - -.empty-icon-box { - width: 40px; - height: 40px; - border-radius: 10px; - background: rgba(99, 102, 241, 0.1); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; -} - -.empty-icon { - color: var(--color-accentedPalette-500); - font-size: 22px; - width: 22px; - height: 22px; -} - -.empty-title { - font-size: 20px; - font-weight: 600; - margin: 0 0 8px; - line-height: 1.3; -} - -.empty-subtitle { - font-size: 14px; - font-weight: 300; - color: rgba(0, 0, 0, 0.64); - margin: 0 0 24px !important; - line-height: 1.5; -} - -.empty-subtitle strong { - font-weight: 500; -} - -@media (prefers-color-scheme: dark) { - .empty-subtitle { - color: rgba(255, 255, 255, 0.64); - } -} - -/* Demo animated table */ -.demo-table { - border-radius: 8px; - overflow: hidden; - background: #1e1e1e; - border: 1px solid #2a2a2a; -} - -.demo-header { - display: flex; - padding: 8px 14px; - border-bottom: 1px solid #2a2a2a; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: rgba(255, 255, 255, 0.35); -} - -.demo-header-col { - width: 110px; - flex-shrink: 0; -} - -.demo-header-val { - flex: 1; -} - -.demo-row { - display: flex; - align-items: center; - padding: 10px 14px; - border-bottom: 1px solid #2a2a2a; - position: relative; -} - -.demo-row--last { - border-bottom: none; -} - -.demo-col { - width: 110px; - flex-shrink: 0; - font-family: monospace; - font-size: 12px; - color: rgba(255, 255, 255, 0.5); -} - -.demo-val-wrap { - flex: 1; - position: relative; - height: 20px; -} - -.demo-raw { - position: absolute; - top: 0; - left: 0; - font-family: monospace; - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - white-space: nowrap; - transition: - opacity 0.35s, - transform 0.35s; - line-height: 20px; -} - -.demo-widget { - position: absolute; - top: 0; - left: 0; - font-family: monospace; - font-size: 12px; - font-weight: 500; - white-space: nowrap; - display: flex; - align-items: center; - gap: 4px; - opacity: 0; - transform: translateY(6px); - transition: - opacity 0.35s, - transform 0.35s; - line-height: 20px; -} - -.show-widget .demo-raw { - opacity: 0; - transform: translateY(-4px); -} - -.show-widget .demo-widget { - opacity: 1; - transform: translateY(0); -} - -.demo-widget--green { - color: #22c55e; -} - -.demo-widget--amber { - color: rgba(255, 255, 255, 0.85); -} - -.demo-widget--purple { - color: #8b5cf6; -} - -.demo-widget--blue { - color: rgba(255, 255, 255, 0.85); -} - -.demo-widget-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.demo-widget-icon--green { - color: #22c55e; -} - -.demo-widget-icon--purple { - color: #8b5cf6; -} - -.demo-widget-icon--blue { - color: #3b82f6; -} - -.widget-badge { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.06); - padding: 2px 8px; - border-radius: 4px; - opacity: 0; - transition: opacity 0.35s; - flex-shrink: 0; - margin-left: 8px; -} - -.widget-badge--visible { - opacity: 1; -} - -@media (prefers-color-scheme: light) { - .demo-table { - background: #f8f8f8; - border-color: rgba(0, 0, 0, 0.1); - } - - .demo-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - color: rgba(0, 0, 0, 0.4); - } - - .demo-row { - border-bottom-color: rgba(0, 0, 0, 0.08); - } - - .demo-col { - color: rgba(0, 0, 0, 0.5); - } - - .demo-raw { - color: rgba(0, 0, 0, 0.4); - } - - .demo-widget--amber { - color: rgba(0, 0, 0, 0.85); - } - - .demo-widget--blue { - color: rgba(0, 0, 0, 0.85); - } - - .widget-badge { - color: rgba(0, 0, 0, 0.35); - background: rgba(0, 0, 0, 0.06); - } -} - -.empty-cta { - margin-top: 24px; - display: flex; - align-items: center; - gap: 16px; -} - -.empty-docs-link { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 13px; - color: var(--color-accentedPalette-500); - text-decoration: none; -} - -.empty-docs-link:hover { - opacity: 0.8; -} - -.empty-docs-icon { - font-size: 14px; - width: 14px; - height: 14px; -} - -.empty-divider { - width: 1px; - background: rgba(0, 0, 0, 0.1); - flex-shrink: 0; - align-self: center; - height: 60%; -} - -@media (prefers-color-scheme: dark) { - .empty-divider { - background: rgba(255, 255, 255, 0.1); - } -} - -.empty-right { - flex: 1; - padding: 32px; - overflow: auto; - display: flex; - flex-direction: column; - justify-content: center; -} - -.widget-grid-label { - text-transform: uppercase; - font-size: 11px; - /* font-weight: 600; */ - letter-spacing: 0.08em; - color: rgba(0, 0, 0, 0.64); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .widget-grid-label { - color: rgba(255, 255, 255, 0.64); - } -} - -.widget-grid { - display: grid; - grid-template-columns: repeat(6, auto); - gap: 10px; -} - -.widget-card { - display: flex; - flex-direction: column; - align-items: center; - padding: 10px 2px 8px; - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 8px; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .widget-card { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.widget-card-icon { - font-size: 24px; - width: 24px; - height: 24px; - color: var(--color-accentedPalette-500); - margin-bottom: 6px; -} - -.widget-card-name { - font-size: 12px; - font-weight: 500; - line-height: 1.3; -} - -.widget-card-desc { - font-size: 11px; - color: rgba(0, 0, 0, 0.54); - line-height: 1.3; -} - -@media (prefers-color-scheme: dark) { - .widget-card-desc { - color: rgba(255, 255, 255, 0.54); - } -} - -@media (width <= 600px) { - .empty-state { - flex-direction: column; - } - - .empty-divider, - .empty-right, - .empty-icon-box { - display: none !important; - } - - .empty-left { - width: 100%; - max-width: 100%; - padding: 16px 0; - } - - .empty-cta { - justify-content: flex-end; - gap: 12px; - } - - .empty-cta > button { - order: 2; - } - - .empty-cta > .empty-docs-link { - order: 1; - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.html deleted file mode 100644 index b32f4e7b5..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.html +++ /dev/null @@ -1,170 +0,0 @@ -
-
-
- tune -
- -

Make your data easier to read and edit

-

By default every column shows raw text. Widgets change how a column is displayed and edited — without modifying the database.

- -
-
- Column - Value -
-
- is_active - - 1 - - check_circle - Yes - - - Boolean -
-
- price - - 9900 - USD 99.00 - - Money -
-
- website - - https://rocketadmin.com - - link - rocketadmin.com - - - URL -
-
- country - - US - - 🇺🇸 United States - - - Country -
-
- -
- - - Docs open_in_new - -
-
- -
- -
- Available widgets -
-
- image - Image - Photo preview -
-
- toggle_on - Boolean - Yes / No -
-
- payments - Money - Currency format -
-
- flag - Country - Country picker -
-
- link - URL - Clickable link -
-
- list - Select - Dropdown list -
-
- event - Date - Date picker -
-
- palette - Color - Color picker -
-
- phone - Phone - Phone format -
-
- code - Code - Syntax highlight -
-
- tag - Number - Numeric display -
-
- linear_scale - Range - Slider input -
-
- password - Password - Hidden text -
-
- notes - Textarea - Multi-line text -
-
- fingerprint - UUID - Auto-generate -
-
- cloud_upload - S3 - File storage -
-
- vpn_key - Foreign key - Linked record -
-
- text_fields - String - Text validation -
-
-
-
diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.ts b/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.ts deleted file mode 100644 index 30bd22d35..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-widgets/widgets-empty-state/widgets-empty-state.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { Angulartics2OnModule } from 'angulartics2'; -import posthog from 'posthog-js'; - -@Component({ - selector: 'app-widgets-empty-state', - templateUrl: './widgets-empty-state.component.html', - styleUrls: ['./widgets-empty-state.component.css'], - imports: [CommonModule, MatButtonModule, MatIconModule, Angulartics2OnModule], -}) -export class WidgetsEmptyStateComponent implements OnInit, OnDestroy { - protected posthog = posthog; - - @Output() onAddWidget = new EventEmitter(); - - public animatedRows: boolean[] = [false, false, false, false]; - - ngOnInit(): void { - this._startWidgetAnimation(); - } - - ngOnDestroy(): void { - this._clearAnimationTimers(); - } - - private _animationTimers: ReturnType[] = []; - - private _startWidgetAnimation(): void { - this._clearAnimationTimers(); - this._runAnimationCycle(); - } - - private _runAnimationCycle(): void { - for (let i = 0; i < 4; i++) { - this._animationTimers.push( - setTimeout(() => { - this.animatedRows[i] = true; - }, i * 600), - ); - } - - this._animationTimers.push( - setTimeout( - () => { - this.animatedRows = [false, false, false, false]; - - this._animationTimers.push( - setTimeout(() => { - this._runAnimationCycle(); - }, 800), - ); - }, - 3 * 600 + 2200, - ), - ); - } - - private _clearAnimationTimers(): void { - this._animationTimers.forEach((t) => clearTimeout(t)); - this._animationTimers = []; - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.css b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.css deleted file mode 100644 index 0bae5269f..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.css +++ /dev/null @@ -1,1064 +0,0 @@ -.filters-content { - display: grid; - grid-template-columns: 32px auto 228px 0 1fr 100px; - grid-column-gap: 8px; - align-content: flex-start; - align-items: center; - padding-top: 4px !important; -} - -::ng-deep mat-dialog-content.filters-content { - padding-left: 24px !important; - padding-right: 24px !important; -} - -.filters-select { - grid-column: 2 / span 5; - margin-bottom: 16px; -} - -.filter-line { - grid-column: 2 / span 4; - display: flex; - align-items: flex-start; - gap: 8px; -} - -.filter-line > div, -.filter-line > ng-template, -.filter-line > ndc-dynamic { - flex: 1; - min-width: 0; -} - -.filter-line > .column-name { - flex: 0 0 auto; - white-space: nowrap; -} - -.empty-conditions-container { - grid-column: 1 / span 5; - display: flex; - align-items: center; - gap: 12px; - margin-top: 8px; - margin-bottom: 16px; -} - -.add-condition-container { - grid-column: 1 / span 6; - display: flex; - align-items: center; - gap: 12px; - margin-top: 8px; - margin-bottom: 16px; -} - -.where-label { - font-weight: 500; - color: rgba(0, 0, 0, 0.87); - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .where-label { - color: rgba(255, 255, 255, 0.87); - } -} - -.empty-conditions-container .column-name-icon { - font-size: 18px; - width: 18px; - height: 18px; - vertical-align: middle; -} - -.empty-condition-input { - width: auto; - min-width: 200px; - max-width: 300px; - margin: 0; - flex-shrink: 0; -} - -.empty-condition-input ::ng-deep .mat-mdc-form-field-infix { - min-height: 36px; - padding-top: 8px; - padding-bottom: 8px; - display: flex; - align-items: center; -} - -.empty-condition-input ::ng-deep .mat-mdc-form-field { - margin: 0; -} - -.empty-condition-input ::ng-deep .mat-mdc-text-field-wrapper { - padding-bottom: 0; - margin-bottom: 0; -} - -.empty-condition-input ::ng-deep .mat-mdc-form-field-subscript-wrapper { - margin-top: 0; - position: absolute; - top: 100%; -} - -.quick-edit-header { - grid-column: 6; - display: flex; - align-items: center; - gap: 4px; - justify-content: center; - margin-top: 8px; - margin-bottom: 16px; -} - -.quick-edit-title { - font-size: 11px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(0, 0, 0, 0.5); - white-space: nowrap; -} - -.quick-edit-info-icon { - font-size: 14px; - width: 14px; - height: 14px; - color: rgba(0, 0, 0, 0.35); - cursor: pointer; -} - -/* Quick edit help menu styles */ -::ng-deep .quick-edit-help-menu { - max-width: none !important; -} - -.quick-edit-help-content { - padding: 16px; - cursor: default; -} - -.quick-edit-help-text { - margin: 0 0 12px 0; - font-size: 13px; - color: rgba(0, 0, 0, 0.7); - line-height: 1.4; -} - -/* Animated example */ -.quick-edit-animated-example { - display: flex; - align-items: center; - gap: 16px; -} - -.example-toggle-animated { - flex-shrink: 0; -} - -.toggle-track { - width: 36px; - height: 20px; - border-radius: 10px; - background-color: rgba(0, 0, 0, 0.25); - position: relative; - animation: toggleTrack 3s ease-in-out infinite; -} - -.toggle-thumb { - width: 16px; - height: 16px; - border-radius: 50%; - background-color: #fff; - position: absolute; - top: 2px; - left: 2px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - animation: toggleThumb 3s ease-in-out infinite; -} - -@keyframes toggleTrack { - 0%, - 40% { - background-color: rgba(0, 0, 0, 0.25); - } - 50%, - 90% { - background-color: #c177fc; - } - 100% { - background-color: rgba(0, 0, 0, 0.25); - } -} - -@keyframes toggleThumb { - 0%, - 40% { - left: 2px; - } - 50%, - 90% { - left: 18px; - } - 100% { - left: 2px; - } -} - -.example-filter-animated { - position: relative; - height: 36px; - min-width: 110px; - margin-top: 4px; -} - -.example-chip-animated { - position: absolute; - top: 0; - left: 0; - background-color: rgba(0, 0, 0, 0.08); - border-radius: 16px; - padding: 5px 12px; - font-size: 12px; - color: rgba(0, 0, 0, 0.7); - white-space: nowrap; - animation: chipFade 3s ease-in-out infinite; -} - -.example-editable-animated { - position: absolute; - top: 0; - left: 0; - display: flex; - align-items: center; - gap: 4px; - background-color: transparent; - border: 1px solid #c177fc; - border-radius: 4px; - padding: 8px 8px 4px 8px; - opacity: 0; - animation: editableFade 3s ease-in-out infinite; -} - -.example-editable-label { - position: absolute; - top: -8px; - left: 8px; - font-size: 10px; - color: #c177fc; - background-color: #fff; - padding: 0 4px; -} - -.example-editable-value { - font-size: 12px; - color: rgba(0, 0, 0, 0.87); - display: flex; - align-items: center; -} - -.typing-text::after { - content: "active"; - animation: typeText 3s ease-in-out infinite; -} - -@keyframes typeText { - 0%, - 40% { - content: ""; - } - 45% { - content: "p"; - } - 50% { - content: "pe"; - } - 55% { - content: "pen"; - } - 60% { - content: "pend"; - } - 65% { - content: "pendi"; - } - 70%, - 90% { - content: "pending"; - } - 100% { - content: ""; - } -} - -.example-typing-cursor { - font-size: 12px; - color: #c177fc; - font-weight: 300; - animation: cursorBlink 0.5s step-end infinite; -} - -@keyframes cursorBlink { - 0%, - 50% { - opacity: 1; - } - 51%, - 100% { - opacity: 0; - } -} - -@keyframes chipFade { - 0%, - 40% { - opacity: 1; - } - 50%, - 90% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes editableFade { - 0%, - 40% { - opacity: 0; - } - 50%, - 90% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -@media (prefers-color-scheme: dark) { - .quick-edit-help-text { - color: rgba(255, 255, 255, 0.7); - } - - .toggle-track { - animation: toggleTrackDark 3s ease-in-out infinite; - } - - @keyframes toggleTrackDark { - 0%, - 40% { - background-color: rgba(255, 255, 255, 0.3); - } - 50%, - 90% { - background-color: #c177fc; - } - 100% { - background-color: rgba(255, 255, 255, 0.3); - } - } - - .example-chip-animated { - background-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.7); - } - - .example-editable-animated { - background-color: transparent; - } - - .example-editable-label { - color: #c177fc; - background-color: #424242; - } - - .example-editable-value { - color: rgba(255, 255, 255, 0.87); - } - - .example-typing-cursor { - color: #c177fc; - } -} - -.quick-edit-toggle { - grid-column: 6; - margin-top: -16px; - align-self: center; - justify-self: center; -} - -/* Hide default icons */ -.quick-edit-toggle ::ng-deep .mdc-switch__icons { - display: none; -} - -/* Style the thumb to contain our icon */ -.quick-edit-toggle ::ng-deep .mdc-switch__handle { - display: flex; - align-items: center; - justify-content: center; -} - -/* Add lock icon when unchecked */ -.quick-edit-toggle ::ng-deep .mdc-switch__handle::after { - font-family: "Material Symbols Outlined"; - content: "lock"; - font-size: 14px; - position: absolute; - color: #fff; -} - -/* Add edit icon when checked */ -.quick-edit-toggle.mat-mdc-slide-toggle-checked ::ng-deep .mdc-switch__handle::after { - font-family: "Material Symbols Outlined"; - content: "edit"; - font-size: 14px; - color: #fff; -} - -@media (prefers-color-scheme: dark) { - .quick-edit-title { - color: rgba(255, 255, 255, 0.5); - } - - .quick-edit-info-icon { - color: rgba(255, 255, 255, 0.35); - } -} - -.comparator-select-field { - grid-column: 3; -} - -.add-condition-footer { - grid-column: 1 / span 6; - margin-top: 24px; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: flex-start; - gap: 16px; -} - -.add-condition-footer button { - width: auto; - min-width: auto; - min-height: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - text-transform: none; - margin: 0 0 16px 0 !important; - padding: 0 16px; -} - -.add-condition-footer .filters-select { - width: auto; - min-width: 200px; - max-width: 300px; - margin: 0 !important; - min-height: 36px; - margin-top: 0 !important; - flex-shrink: 0; -} - -.add-condition-footer .filters-select ::ng-deep .mat-mdc-form-field-infix { - min-height: 36px; - padding-top: 8px; - padding-bottom: 8px; - display: flex; - align-items: center; -} - -.add-condition-footer .filters-select ::ng-deep .mat-mdc-text-field-wrapper { - padding-bottom: 0; - margin-bottom: 0; -} - -.add-condition-footer ::ng-deep .mat-mdc-form-field { - margin-top: 0 !important; - margin-bottom: 0 !important; - margin-left: 0 !important; - margin-right: 0 !important; -} - -.add-condition-footer ::ng-deep .mat-mdc-form-field-wrapper { - padding-bottom: 0; -} - -.filter-save-form { - grid-column: 1 / span 6; - margin-bottom: 24px; -} - -.section-title { - grid-column: 1 / span 6; - margin: 16px 0 8px 0; - - font-weight: 500; -} - -@media (prefers-color-scheme: light) { - .section-title { - color: rgba(0, 0, 0, 0.87); - } -} - -@media (prefers-color-scheme: dark) { - .section-title { - color: rgba(255, 255, 255, 0.87); - } -} - -.section-description { - grid-column: 1 / span 6; - margin: 0 0 16px 0; - font-size: 14px; - white-space: nowrap; -} - -@media (prefers-color-scheme: light) { - .section-description { - color: rgba(0, 0, 0, 0.6); - } -} - -@media (prefers-color-scheme: dark) { - .section-description { - color: rgba(255, 255, 255, 0.6); - } -} - -.full-width { - grid-column: 1 / -1; -} - -.full-width.filter-name-field { - max-width: 400px; -} - -.default-filter-checkbox { - margin-bottom: 16px; -} - -::ng-deep .mat-dialog-container > .ng-star-inserted { - display: flex; - flex-direction: column; - width: 100%; -} - -.filters-form { - flex: 1 1 auto; - display: flex; - flex-direction: column; - min-height: 0; -} - -.filters-content { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; -} - -::ng-deep .filters-form mat-dialog-actions { - flex: 0 0 auto; - position: sticky; - bottom: 0; - background: var(--mat-dialog-container-color, #fff); - z-index: 2; - border-top: 1px solid rgba(0, 0, 0, 0.08); -} - -@media (prefers-color-scheme: dark) { - ::ng-deep .filters-form mat-dialog-actions { - background: var(--mat-sidenav-content-background-color); - border-top-color: rgba(255, 255, 255, 0.08); - } -} - -.filters-header-description { - margin: 0 24px 16px 24px; - font-size: 14px; - line-height: 1.5; -} - -@media (prefers-color-scheme: light) { - .filters-header-description { - color: rgba(0, 0, 0, 0.6); - } -} - -@media (prefers-color-scheme: dark) { - .filters-header-description { - color: rgba(255, 255, 255, 0.6); - } -} - -::ng-deep .mat-dialog-container { - display: flex; -} - -.column-name { - grid-column: 2; - margin-top: -8px; - align-self: center; -} - -.conditions-vertical-line { - grid-column: 1; - grid-row: 2 / -1; - width: 1px; - background-color: rgba(0, 0, 0, 0.12); - margin-left: 9px; - margin-top: 0; - margin-bottom: 0; - z-index: 0; -} - -@media (prefers-color-scheme: dark) { - .conditions-vertical-line { - background-color: rgba(255, 255, 255, 0.12); - } -} - -.filter-delete-button { - grid-column: 1; - margin-top: -20px; - margin-right: 0; - margin-left: 0; - align-self: center; - justify-self: center; - position: relative; - z-index: 1; - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; -} - -.filter-delete-button ::ng-deep .mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - line-height: 18px; -} - -.filters-header { - margin-top: 16px; - margin-bottom: 12px; - min-height: 0 !important; - line-height: 1.2 !important; -} - -.condition-card { - grid-column: 1 / -1; - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 0; - border-bottom: 1px dashed rgba(0, 0, 0, 0.12); -} - -.condition-card:last-of-type { - border-bottom: none; -} - -@media (prefers-color-scheme: dark) { - .condition-card { - border-bottom-color: rgba(255, 255, 255, 0.12); - } -} - -.condition-card__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.condition-card__column-name { - font-size: 12px; - font-weight: 600; - opacity: 0.6; -} - -.condition-card .filter-delete-button { - grid-column: auto !important; - margin: 0 !important; - width: 32px !important; - height: 32px !important; - flex: 0 0 32px; - opacity: 0.7; -} - -.condition-card .filter-delete-button:hover { - opacity: 1; -} - -.condition-card .filter-delete-button ::ng-deep .mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - line-height: 18px; -} - -.condition-card__inputs { - display: flex; - gap: 4px; - align-items: flex-start; - flex-wrap: wrap; -} - -.condition-card__inputs ::ng-deep .mat-mdc-form-field:not(.comparator-select-field) .mat-mdc-text-field-wrapper { - padding-left: 12px !important; - padding-right: 12px !important; -} - -.condition-card__inputs ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -.condition-card__inputs > * { - flex: 1 1 0; - min-width: 0; -} - -.condition-card__inputs > .comparator-select-field { - flex: 0 0 auto; -} - -/* ndc-dynamic renders the component as a sibling and leaves an empty anchor element behind; keep it from taking up flex space */ -.condition-card__inputs > ndc-dynamic { - flex: 0 0 auto; - width: auto; -} - -/* the actual component is rendered as the sibling right after the anchor; let it (and its host child) fill the available space */ -.condition-card__inputs > ndc-dynamic + *, -.condition-card__inputs ::ng-deep ndc-dynamic + * { - flex: 1 1 0; - width: 100%; - min-width: 0; -} - -.comparator-trigger__symbol { - display: none; -} - -.comparator-trigger__text { - display: inline; -} - -@media (width <= 600px) { - .condition-card__inputs > .comparator-select-field { - flex: 0 0 56px !important; - } - - .comparator-trigger__symbol { - display: inline; - } - - .comparator-trigger__text { - display: none; - } -} - -.condition-card__value { - display: flex; - min-width: 0; - flex: 1 1 0; -} - -.condition-card__value > *, -.condition-card__value ::ng-deep .mat-mdc-form-field { - flex: 1 1 auto; - width: 100%; - min-width: 0; -} - -/* ndc-dynamic renders the component as a sibling and leaves an empty anchor element behind; keep it from taking up flex space */ -.condition-card__value > ndc-dynamic { - flex: 0 0 auto; - width: auto; -} - -/* the actual component is rendered as the sibling right after the anchor; let it (and its host child) fill the available space */ -.condition-card__value > ndc-dynamic + *, -.condition-card__value ::ng-deep ndc-dynamic + * { - flex: 1 1 0; - width: 100%; - min-width: 0; -} - -.condition-card__value ::ng-deep .full-width { - width: 100%; -} - -.condition-card__inputs .comparator-select-field { - flex: 0 0 auto; -} - -@media (width <= 600px) { - .condition-card__inputs .comparator-select-field { - flex: 0 0 56px !important; - width: 56px !important; - max-width: 56px !important; - min-width: 0 !important; - } - - .condition-card__inputs .comparator-select-field ::ng-deep .mat-mdc-text-field-wrapper { - width: 56px !important; - min-width: 0 !important; - padding-left: 8px !important; - padding-right: 4px !important; - } -} - -@media (width <= 600px) { - .condition-card__inputs .comparator-select-field ::ng-deep .mat-mdc-form-field-flex { - min-width: 0 !important; - } - - .condition-card__inputs .comparator-select-field ::ng-deep .mat-mdc-form-field-infix { - min-width: 0 !important; - width: auto !important; - padding-left: 0 !important; - padding-right: 0 !important; - } - - .condition-card__inputs .comparator-select-field ::ng-deep .mat-mdc-select-arrow-wrapper { - padding-left: 0; - } -} - -.condition-card__inputs .comparator-select-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none !important; -} - -.condition-card__footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding-top: 4px; -} - -.condition-card__editable-label { - font-size: 12px; - opacity: 0.65; -} - -.condition-card__help-icon { - font-size: 16px; - width: 16px; - height: 16px; - cursor: pointer; - opacity: 0.5; - margin-right: auto; -} - -.condition-card__help-icon:hover { - opacity: 0.85; -} - -.condition-card .quick-edit-toggle { - flex: 0 0 auto; -} - -.filters-header::before { - height: 0 !important; - content: none !important; -} - -.filters-header__text { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - line-height: 1.2; - text-align: left; -} - -.filters-header__label { - font-size: 18px; - font-weight: 600; -} - -.filters-header__table { - font-size: 12px; - font-weight: 500; - opacity: 0.6; -} - -@media (width <= 600px) { - .filters-content { - grid-template-columns: 32px 1fr auto !important; - grid-row-gap: 0 !important; - align-items: start !important; - padding-left: 12px !important; - padding-right: 12px !important; - } - - .filters-select { - grid-column: 1 / -1 !important; - } - - .filter-line { - grid-column: 2 / span 2 !important; - flex-direction: column; - gap: 4px; - padding-right: 0; - } - - .filter-line > .column-name { - font-size: 12px; - font-weight: 600; - opacity: 0.6; - margin: 0; - flex: 1 1 100%; - align-self: flex-start; - } - - .filter-line > div, - .filter-line > ndc-dynamic, - .filter-line > ng-template { - flex: 1 1 100%; - width: 100%; - } - - .column-name { - font-size: 12px !important; - font-weight: 600 !important; - opacity: 0.6; - margin-top: 0 !important; - } - - .empty-conditions-container, - .add-condition-container { - grid-column: 1 / -1 !important; - } - - .filter-delete-button { - grid-column: 1 !important; - margin: 0 !important; - align-self: start !important; - } - - .quick-edit-toggle { - grid-column: 1 / -1 !important; - justify-self: flex-end; - margin: 0 0 12px !important; - align-self: start !important; - } - - .filter-line ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - .conditions-vertical-line { - display: none !important; - } -} - -.settings-form__reset-button { - margin-right: auto; -} - -.no-filters-message { - grid-column: 1 / span 6; - color: rgba(0, 0, 0, 0.6); - font-style: italic; - margin: 16px 0; -} - -.comparator-icon { - font-size: 18px; - width: 18px; - height: 18px; - margin-right: 8px; - vertical-align: middle; -} - -::ng-deep .mat-mdc-select-panel .mat-mdc-option { - display: flex; - align-items: center; -} - -.comparator-select-field { - width: auto; - min-width: 140px; - max-width: none; -} - -.comparator-select-field ::ng-deep .mat-mdc-form-field-infix { - width: 100% !important; - min-width: fit-content; - padding-left: 0; - position: relative; -} - -.comparator-select-field ::ng-deep .mat-mdc-select-trigger { - width: 100%; - min-width: fit-content; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; -} - -.comparator-select-field ::ng-deep .mat-mdc-select-value-text { - width: auto; - min-width: fit-content; - flex: 1; -} - -.comparator-select-field ::ng-deep .mat-mdc-select-arrow-wrapper { - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - pointer-events: none; -} - -.comparator-select-field ::ng-deep .mat-mdc-select-value { - padding-right: 24px; -} - -.comparator-select-field ::ng-deep .mat-mdc-select-panel { - min-width: max-content !important; - width: max-content !important; -} - -.conditions-error-message { - grid-column: 1 / span 6; - color: #f44336; - font-size: 12px; - margin: 8px 0 0 0; - padding-left: 16px; -} - -/* Add more spacing for multiline textarea inputs (more than 2 rows) */ -.filters-content ::ng-deep - .filter-line - mat-form-field:has(textarea[rows]):not(:has(textarea[rows="1"])):not(:has(textarea[rows="2"])), -.filters-content ::ng-deep .filter-line mat-form-field:has(textarea.long-textarea), -.filters-content ::ng-deep .filter-line mat-form-field:has(textarea.form-textarea) { - margin-top: 24px !important; - margin-bottom: 24px !important; -} - -/* Add more spacing for foreign key fields */ -.filters-content ::ng-deep .filter-line .foreign-key, -.filters-content ::ng-deep .filter-line app-edit-foreign-key, -.filters-content ::ng-deep .foreign-key { - margin-top: 24px !important; - margin-bottom: 24px !important; -} diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.html b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.html deleted file mode 100644 index da9f2ddc2..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.html +++ /dev/null @@ -1,296 +0,0 @@ -
-

- - {{ data.filtersSet.id ? 'Edit fast filter' : 'New fast filter' }} - Table: {{ data.displayTableName }} - -

-

- Use conditions to quickly filter {{ data.displayTableName.toLowerCase() }} and optionally edit one column. -

- - - - - - Fast filter name - - - Fast filter name is required - - - - - - Click here to add condition - - - - {{field}} - - - - - - -
- subdirectory_arrow_right - where -
- -
-

Enable to change this value
directly from the table view

-
-
-
-
-
-
-
-
- status = active -
-
- status: - - - | - -
-
-
-
-
- - -
-
- {{value.key}} - -
- -
- - - - - - - - - - - - - - - - {{ {startswith: 'a…', endswith: '…a', eq: '=', contains: '∋', icontains: '∌', empty: '∅'}[tableRowFieldsComparator[value.key]] || tableRowFieldsComparator[value.key] }} - - - {{ {startswith: 'starts with', endswith: 'ends with', eq: 'equal', contains: 'contains', icontains: 'not contains', empty: 'is empty'}[tableRowFieldsComparator[value.key]] || tableRowFieldsComparator[value.key] }} - - - - play_arrow - starts with - - - play_arrow - ends with - - - drag_handle - equal - - - search - contains - - - block - not contains - - - space_bar - is empty - - - - - - - - - {{ {eq: '=', gt: '>', lt: '<', gte: '≥', lte: '≤'}[tableRowFieldsComparator[value.key]] || tableRowFieldsComparator[value.key] }} - - - {{ {eq: 'equal', gt: 'greater than', lt: 'less than', gte: 'greater than or equal', lte: 'less than or equal'}[tableRowFieldsComparator[value.key]] || tableRowFieldsComparator[value.key] }} - - - - drag_handle - equal - - - keyboard_arrow_right - greater than - - - keyboard_arrow_left - less than - - - keyboard_double_arrow_right - greater than or equal - - - keyboard_double_arrow_left - less than or equal - - - - -
- -
-
-
- - -
- - -
- - - Click here to add condition - - - - {{field}} - - - - - At least one condition is required - - -

- At least one condition is required -

-
-
-
- - - - - - -
diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.spec.ts deleted file mode 100644 index 142585512..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.spec.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { SavedFiltersDialogComponent } from './saved-filters-dialog.component'; - -describe('SavedFiltersDialogComponent', () => { - let component: SavedFiltersDialogComponent; - let fixture: ComponentFixture; - let tablesServiceMock: any; - let _connectionsServiceMock: any; - - beforeEach(async () => { - const tableSpy = { - cast: { subscribe: vi.fn() }, - createSavedFilter: vi.fn(), - deleteSavedFilter: vi.fn(), - updateSavedFilter: vi.fn(), - }; - - const connectionSpy = { - currentConnection: { type: 'postgres' }, - }; - - await TestBed.configureTestingModule({ - imports: [SavedFiltersDialogComponent, RouterTestingModule, Angulartics2Module.forRoot()], - providers: [ - { provide: TablesService, useValue: tableSpy }, - { provide: ConnectionsService, useValue: connectionSpy }, - { provide: MatDialogRef, useValue: { close: vi.fn() } }, - { provide: MatSnackBar, useValue: { open: vi.fn() } }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - paramMap: { get: () => {} }, - queryParamMap: { get: () => {} }, - }, - }, - }, - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionID: '123', - tableName: 'test_table', - displayTableName: 'Test Table', - filtersSet: { - name: 'Test Filter', - filters: {}, - }, - structure: [], - tableForeignKeys: {}, - tableWidgets: [], - }, - }, - ], - }).compileComponents(); - - tablesServiceMock = TestBed.inject(TablesService); - _connectionsServiceMock = TestBed.inject(ConnectionsService); - - fixture = TestBed.createComponent(SavedFiltersDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should toggle dynamic column', () => { - const fieldName = 'test_field'; - - // Initially null - expect(component.dynamicColumn).toBeNull(); - - // First toggle sets to fieldName - component.toggleDynamicColumn(fieldName); - expect(component.dynamicColumn).toBe(fieldName); - - // Second toggle sets back to null - component.toggleDynamicColumn(fieldName); - expect(component.dynamicColumn).toBeNull(); - }); - - it('should exclude dynamic column from filters and include it with column_name and comparator', () => { - // Setup - const fieldName = 'test_field'; - component.tableRowFieldsShown = { [fieldName]: 'test_value' }; - component.tableRowFieldsComparator = { [fieldName]: 'eq' }; - component.dynamicColumn = fieldName; - tablesServiceMock.createSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify - filters should be empty, and dynamic_column should have column_name and comparator - expect(tablesServiceMock.createSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - { - name: component.data.filtersSet.name, - filters: {}, - dynamic_column: { - column_name: fieldName, - comparator: 'eq', - }, - }, - ); - }); - - it('should include dynamic column with column_name and comparator and exclude it from filters', () => { - // Setup - const fieldName = 'test_field'; - const dynamicFieldName = 'dynamic_field'; - component.tableRowFieldsShown = { - [fieldName]: 'test_value', - [dynamicFieldName]: 'dynamic_value', - }; - component.tableRowFieldsComparator = { - [fieldName]: 'eq', - [dynamicFieldName]: 'contains', - }; - component.dynamicColumn = dynamicFieldName; // Different field from the one with comparator - tablesServiceMock.createSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify - only fieldName in filters, dynamicFieldName in dynamic_column - expect(tablesServiceMock.createSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - { - name: component.data.filtersSet.name, - filters: { [fieldName]: { eq: 'test_value' } }, - dynamic_column: { - column_name: dynamicFieldName, - comparator: 'contains', - }, - }, - ); - }); - - it('should handle empty values in filters as null', () => { - // Setup - const fieldName = 'test_field'; - component.tableRowFieldsShown = { [fieldName]: '' }; // Empty value - component.tableRowFieldsComparator = { [fieldName]: 'eq' }; - tablesServiceMock.createSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify - value should be null instead of empty string - expect(tablesServiceMock.createSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - { - name: component.data.filtersSet.name, - filters: { [fieldName]: { eq: null } }, - }, - ); - }); - - it('should handle dynamic column with no comparator', () => { - // Setup - const fieldName = 'test_field'; - const dynamicFieldName = 'dynamic_field_no_comparator'; - component.tableRowFieldsShown = { - [fieldName]: 'test_value', - [dynamicFieldName]: 'dynamic_value', - }; - component.tableRowFieldsComparator = { - [fieldName]: 'eq', - // No comparator for dynamicFieldName - }; - component.dynamicColumn = dynamicFieldName; - tablesServiceMock.createSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify - dynamic_column should have empty comparator - expect(tablesServiceMock.createSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - { - name: component.data.filtersSet.name, - filters: { [fieldName]: { eq: 'test_value' } }, - dynamic_column: { - column_name: dynamicFieldName, - comparator: '', - }, - }, - ); - }); - - it('should update existing filter when id is present', () => { - // Setup - const fieldName = 'test_field'; - const filterId = '123'; - component.tableRowFieldsShown = { [fieldName]: 'test_value' }; - component.tableRowFieldsComparator = { [fieldName]: 'eq' }; - component.data.filtersSet.id = filterId; - tablesServiceMock.updateSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify updateSavedFilter is called instead of createSavedFilter - expect(tablesServiceMock.updateSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - filterId, - { - name: component.data.filtersSet.name, - filters: { [fieldName]: { eq: 'test_value' } }, - }, - ); - expect(tablesServiceMock.createSavedFilter).not.toHaveBeenCalled(); - }); - - it('should create new filter when id is not present', () => { - // Setup - const fieldName = 'test_field'; - component.tableRowFieldsShown = { [fieldName]: 'test_value' }; - component.tableRowFieldsComparator = { [fieldName]: 'eq' }; - component.data.filtersSet.id = undefined; // No ID means creating a new filter - tablesServiceMock.createSavedFilter.mockReturnValue(of({})); - - // Call handleSaveFilters - component.handleSaveFilters(); - - // Verify createSavedFilter is called - expect(tablesServiceMock.createSavedFilter).toHaveBeenCalledWith( - component.data.connectionID, - component.data.tableName, - { - name: component.data.filtersSet.name, - filters: { [fieldName]: { eq: 'test_value' } }, - }, - ); - expect(tablesServiceMock.updateSavedFilter).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.ts deleted file mode 100644 index a44481123..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-dialog/saved-filters-dialog.component.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import posthog from 'posthog-js'; -import { map, Observable, startWith } from 'rxjs'; -import { ContentLoaderComponent } from 'src/app/components/ui-components/content-loader/content-loader.component'; -import { UIwidgets as FilterUIwidgets, filterTypes } from 'src/app/consts/filter-types'; -import { UIwidgets as EditUIwidgets } from 'src/app/consts/record-edit-types'; -import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; -import { TableField } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; - -@Component({ - selector: 'app-saved-filters-dialog', - imports: [ - CommonModule, - ReactiveFormsModule, - FormsModule, - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatSelectModule, - MatSlideToggleModule, - MatMenuModule, - MatCheckboxModule, - MatRadioModule, - DynamicModule, - SignalComponentIoModule, - RouterModule, - MatDialogModule, - MatSnackBarModule, - ContentLoaderComponent, - Angulartics2OnModule, - MatTooltipModule, - ], - templateUrl: './saved-filters-dialog.component.html', - styleUrl: './saved-filters-dialog.component.css', -}) -export class SavedFiltersDialogComponent implements OnInit, AfterViewInit { - public tableFilters = []; - public fieldSearchControl = new FormControl(''); - public fields: string[]; - public foundFields: Observable; - - public tableRowFields: Object; - public tableRowStructure: Object; - public tableRowFieldsShown: Object = {}; - public tableRowFieldsComparator: Object = {}; - public tableFiltersCount: number = 0; - public tableTypes: Object; - public tableWidgets: object; - public UIwidgets = { ...EditUIwidgets, ...FilterUIwidgets }; - public dynamicColumn: string | null = null; - public showAddConditionField = false; - public showNameError = false; - public showConditionsError = false; - - @ViewChild('tableFiltersForm') tableFiltersForm: any; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _tables: TablesService, - private _connections: ConnectionsService, - private dialogRef: MatDialogRef, - private snackBar: MatSnackBar, - private angulartics2: Angulartics2, - private elementRef: ElementRef, - ) {} - - ngOnInit(): void { - if (this.data.filtersSet) { - this.tableRowFieldsShown = Object.entries(this.data.filtersSet.filters).reduce((acc, [field, conditions]) => { - const [_comparator, value] = Object.entries(conditions)[0]; - acc[field] = value; - return acc; - }, {}); - - this.tableRowFieldsComparator = Object.entries(this.data.filtersSet.filters).reduce( - (acc, [field, conditions]) => { - const [comparator] = Object.keys(conditions); - acc[field] = comparator; - return acc; - }, - {}, - ); - - if (this.data.filtersSet.dynamic_column?.column_name) { - this.tableRowFieldsShown[this.data.filtersSet.dynamic_column.column_name] = null; - this.tableRowFieldsComparator[this.data.filtersSet.dynamic_column.column_name] = - this.data.filtersSet.dynamic_column.comparator || ''; - this.dynamicColumn = this.data.filtersSet.dynamic_column.column_name; - } - } - - this.tableRowFields = Object.assign( - {}, - ...this.data.structure.map((field: TableField) => ({ [field.column_name]: undefined })), - ); - const foreignKeysList = Object.keys(this.data.tableForeignKeys); - this.tableTypes = getTableTypes(this.data.structure, foreignKeysList); - this.fields = this.data.structure - .filter((field: TableField) => this.getInputType(field.column_name) !== 'file') - .map((field: TableField) => field.column_name); - - this.tableRowStructure = Object.assign( - {}, - ...this.data.structure.map((field: TableField) => { - return { [field.column_name]: field }; - }), - ); - - const tableWidgets = this.data.tableWidgets; - if (tableWidgets && Object.keys(tableWidgets).length) { - this.tableWidgets = tableWidgets; - } - - this.foundFields = this.fieldSearchControl.valueChanges.pipe( - startWith(''), - map((value) => this._filter(value || '')), - ); - } - - ngAfterViewInit(): void { - if (this.data.filtersSet && this.data.filtersSet.id) { - setTimeout(() => { - const nameInput = this.elementRef.nativeElement.querySelector( - 'input[name="filters_set_name"]', - ) as HTMLInputElement; - if (nameInput && document.activeElement === nameInput) { - nameInput.blur(); - } - }, 100); - } - } - - get hasSelectedFilters(): boolean { - return Object.keys(this.tableRowFieldsShown).length > 0; - } - - handleAddConditionButtonClick(): void { - this.showAddConditionField = true; - setTimeout(() => { - const input = this.elementRef.nativeElement.querySelector('input[name="filter_columns"]') as HTMLInputElement; - input?.focus(); - }, 0); - } - - cancelAddConditionInput(): void { - this.showAddConditionField = false; - this.fieldSearchControl.setValue(''); - } - - private _filter(value: string): string[] { - return this.fields.filter((field: string) => field.toLowerCase().includes(value.toLowerCase())); - } - - get inputs() { - return filterTypes[this._connections.currentConnection.type]; - } - - trackByFn(_index: number, item: any) { - return item.key; - } - - isWidget(columnName: string) { - return this.tableWidgets && columnName in this.tableWidgets; - } - - updateComparatorFromComponent = (comparator: string, field: string) => { - this.tableRowFieldsComparator[field] = comparator; - }; - - updateField = (updatedValue: any, field: string) => { - this.tableRowFieldsShown[field] = updatedValue; - this.updateFiltersCount(); - if (this.showConditionsError && Object.keys(this.tableRowFieldsShown).length > 0) { - this.showConditionsError = false; - } - }; - - addFilter(e) { - const key = e.option.value; - this.tableRowFieldsShown = { ...this.tableRowFieldsShown, [key]: this.tableRowFields[key] }; - this.tableRowFieldsComparator = { - ...this.tableRowFieldsComparator, - [key]: this.tableRowFieldsComparator[key] || 'eq', - }; - this.fieldSearchControl.setValue(''); - this.updateFiltersCount(); - this.showConditionsError = false; - if (this.hasSelectedFilters) { - this.showAddConditionField = false; - } - } - - handleInputBlur(): void { - if (!this.fieldSearchControl.value || this.fieldSearchControl.value.trim() === '') { - setTimeout(() => { - if (!this.fieldSearchControl.value || this.fieldSearchControl.value.trim() === '') { - this.cancelAddConditionInput(); - } - }, 200); - } - } - - updateComparator(event, fieldName: string) { - if (event === 'empty') this.tableRowFieldsShown[fieldName] = ''; - } - - removeFilters() { - this._tables.deleteSavedFilter(this.data.connectionID, this.data.tableName, this.data.filtersSet.id).subscribe({ - next: () => { - this.dialogRef.close(true); - }, - error: (error) => { - console.error('Error removing filters:', error); - this.snackBar.open('Error removing filters', 'Close', { duration: 3000 }); - }, - }); - } - - getInputType(field: string) { - let widgetType; - if (this.isWidget(field)) { - widgetType = this.UIwidgets[this.tableWidgets[field].widget_type]?.type; - } else { - widgetType = this.inputs[this.tableTypes[field]]?.type; - } - return widgetType; - } - - getComparatorType(typeOfComponent) { - if (typeOfComponent === 'text') { - return 'text'; - } else if (typeOfComponent === 'number' || typeOfComponent === 'datetime') { - return 'number'; - } else { - return 'nonComparable'; - } - } - - getOperatorIcon(operator: string): string { - const iconMap: { [key: string]: string } = { - startswith: 'play_arrow', - endswith: 'play_arrow', - eq: 'drag_handle', - contains: 'search', - icontains: 'block', - empty: 'space_bar', - gt: 'keyboard_arrow_right', - lt: 'keyboard_arrow_left', - gte: 'keyboard_double_arrow_right', - lte: 'keyboard_double_arrow_left', - }; - return iconMap[operator] || 'drag_handle'; - } - - removeFilter(field) { - delete this.tableRowFieldsShown[field]; - delete this.tableRowFieldsComparator[field]; - if (this.dynamicColumn === field) { - this.dynamicColumn = null; - } - this.updateFiltersCount(); - this.showConditionsError = false; - if (!this.hasSelectedFilters) { - this.showAddConditionField = false; - } - } - - updateFiltersCount() { - this.tableFiltersCount = Object.keys(this.tableRowFieldsShown).length; - } - - toggleDynamicColumn(field: string) { - if (this.dynamicColumn === field) { - this.dynamicColumn = null; - } else { - this.dynamicColumn = field; - } - } - - handleSaveFilters() { - this.showNameError = false; - this.showConditionsError = false; - - if (!this.data.filtersSet.name || this.data.filtersSet.name.trim() === '') { - this.showNameError = true; - setTimeout(() => { - const nameInput = this.elementRef.nativeElement.querySelector( - 'input[name="filters_set_name"]', - ) as HTMLInputElement; - if (nameInput) { - nameInput.focus(); - nameInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, 0); - return; - } - - const hasRegularFilters = Object.keys(this.tableRowFieldsShown).some((key) => { - if (key === this.dynamicColumn) { - return false; - } - return this.tableRowFieldsComparator[key] !== undefined && this.tableRowFieldsComparator[key] !== null; - }); - - const hasDynamicColumnFilter = - this.dynamicColumn && - this.tableRowFieldsComparator[this.dynamicColumn] !== undefined && - this.tableRowFieldsComparator[this.dynamicColumn] !== null; - - if (!hasRegularFilters && !hasDynamicColumnFilter) { - this.showConditionsError = true; - setTimeout(() => { - const conditionInput = this.elementRef.nativeElement.querySelector( - 'input[name="filter_columns"]', - ) as HTMLInputElement; - if (conditionInput) { - conditionInput.focus(); - conditionInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } else { - const addButton = this.elementRef.nativeElement.querySelector('.add-condition-footer button') as HTMLElement; - if (addButton) { - addButton.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } - }, 0); - return; - } - - let payload; - if (Object.keys(this.tableRowFieldsShown).length) { - let filters = {}; - - for (const key in this.tableRowFieldsShown) { - if (key === this.dynamicColumn) { - continue; - } - - if (this.tableRowFieldsComparator[key] !== undefined) { - const value = - this.tableRowFieldsShown[key] === '' || this.tableRowFieldsShown[key] === undefined - ? null - : this.tableRowFieldsShown[key]; - - filters[key] = { - [this.tableRowFieldsComparator[key]]: value, - }; - } - } - - payload = { - name: this.data.filtersSet.name, - filters, - }; - - if (this.dynamicColumn) { - payload.dynamic_column = { - column_name: this.dynamicColumn, - comparator: this.tableRowFieldsComparator[this.dynamicColumn] || '', - }; - } - - if (this.data.filtersSet.id) { - this._tables - .updateSavedFilter(this.data.connectionID, this.data.tableName, this.data.filtersSet.id, payload) - .subscribe( - () => { - this.dialogRef.close(true); - }, - (error) => { - console.error('Error updating filter:', error); - this.snackBar.open('Error updating filter', 'Close', { duration: 3000 }); - }, - ); - } else { - this._tables.createSavedFilter(this.data.connectionID, this.data.tableName, payload).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Saved filters: saved filter is created successfully', - }); - posthog.capture('Saved filters: saved filter is created successfully'); - this.dialogRef.close(true); - }, - (error) => { - this.angulartics2.eventTrack.next({ - action: 'Saved filters: error creating saved filter', - }); - posthog.capture('Saved filters: error creating saved filter'); - console.error('Error saving filter:', error); - this.snackBar.open('Error saving filter', 'Close', { duration: 3000 }); - }, - ); - } - } - } -} diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.css b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.css deleted file mode 100644 index 8a3ae464b..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.css +++ /dev/null @@ -1,676 +0,0 @@ -.saved-filters-container { - display: flex; - flex-direction: column; - min-width: 0; - max-width: 100%; -} - -.saved-filters-list { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 12px; -} - -.saved-filters-list__first-time-button { - transition: background 0.3s ease; -} - -.saved-filters-trigger { - display: none; - align-items: center; - gap: 6px; - border-radius: 999px; - padding: 0 8px 0 12px; - min-height: 36px; -} - -@media (width <= 600px) { - .saved-filters-trigger_active { - width: calc(100vw - 130px - 2*24px); - box-sizing: border-box; - } - - /* let the button's label shrink and ellipsis when it can't fit into calc(100% - 130px) */ - .saved-filters-trigger_active ::ng-deep .mdc-button__label { - display: flex; - align-items: center; - min-width: 0; - overflow: hidden; - } - - .saved-filters-trigger_active .saved-filters-trigger__label { - max-width: none; - min-width: 0; - flex: 1 1 auto; - } - - .saved-filters-list { - display: none !important; - } -} - -.saved-filters-trigger__label { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.saved-filters-trigger__chevron { - margin-left: 0 !important; -} - -@media (prefers-color-scheme: light) { - .saved-filters-trigger_active { - background: var(--color-accentedPalette-50); - border-color: var(--color-accentedPalette-300) !important; - color: var(--color-accentedPalette-700); - } - - .saved-filters-trigger_active .saved-filters-trigger__icon { - color: var(--color-accentedPalette-700); - } -} - -@media (prefers-color-scheme: dark) { - .saved-filters-trigger_active { - background: var(--color-accentedPalette-900); - border-color: var(--color-accentedPalette-600) !important; - color: var(--color-accentedPalette-100); - } - - .saved-filters-trigger_active .saved-filters-trigger__icon { - color: var(--color-accentedPalette-200); - } -} - -.saved-filters-menu__check { - color: var(--color-accentedPalette-600); - visibility: hidden; - flex-shrink: 0; -} - -.saved-filters-menu__check_visible { - visibility: visible; -} - -.saved-filters-menu__item_active .saved-filters-menu__name { - font-weight: 600; -} - -::ng-deep .saved-filters-menu .mat-mdc-menu-item .mat-mdc-menu-item-text { - display: inline-flex; - align-items: center; - width: 100%; - gap: 8px; -} - -.saved-filters-menu__name { - flex: 1 1 auto; - display: inline-flex; - align-items: center; -} - -.saved-filters-menu__row-menu { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - margin-left: auto; - margin-right: -8px; - border-radius: 50%; - cursor: pointer; - flex-shrink: 0; -} - -.saved-filters-menu__row-menu .mat-icon { - margin: 0 !important; - width: 20px; - height: 20px; - font-size: 20px; -} - -.saved-filters-menu__row-menu:hover { - background: rgba(0, 0, 0, 0.06); -} - -@media (prefers-color-scheme: dark) { - .saved-filters-menu__row-menu:hover { - background: rgba(255, 255, 255, 0.08); - } -} - -.saved-filters-menu__create { - border-top: 1px solid rgba(0, 0, 0, 0.08); -} - -@media (prefers-color-scheme: dark) { - .saved-filters-menu__create { - border-top-color: rgba(255, 255, 255, 0.08); - } -} - -@media (prefers-color-scheme: light) { - .saved-filters-list__first-time-button { - background: var(--color-accentedPalette-50); - border: 1px dashed var(--color-accentedPalette-300); - } - - .saved-filters-list__first-time-button:hover { - background: var(--color-accentedPalette-100) !important; - } -} - -@media (prefers-color-scheme: dark) { - .saved-filters-list__first-time-button { - background: var(--color-accentedPalette-900); - border: 1px dashed var(--color-accentedPalette-600); - } - - .saved-filters-list__first-time-button:hover { - background: var(--color-accentedPalette-800) !important; - } -} - -/* .saved-filters-tabs { - --mat-chip-container-shape-radius: 4px; -} */ - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip { - --mat-chip-container-shape-radius: 4px; - --mat-chip-outline-width: 1px; - --mat-chip-elevated-container-color: transparent !important; - --mat-chip-elevated-selected-container-color: var(--color-accentedPalette-500) !important; - --mat-chip-container-height: 32px; - --mat-chip-label-text-color: rgba(0, 0, 0, 0.64); - --mdc-chip-container-shape-radius: 4px; - --mdc-chip-outline-width: 1px; - --mdc-chip-elevated-container-color: transparent !important; - --mdc-chip-elevated-selected-container-color: var(--color-accentedPalette-500) !important; - --mdc-chip-container-height: 32px; - --mdc-chip-label-text-color: rgba(0, 0, 0, 0.64); - --mdc-chip-selected-container-shape-leading-width: 0px; - --mdc-chip-with-icon-graphic-selected-container-shape-leading-width: 0px; - --mdc-chip-with-icon-graphic-selected-container-shape-leading-start-width: 0px; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip.mdc-evolution-chip--selected { - --mdc-chip-selected-container-elevation-shadow: none; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip .mdc-evolution-chip__checkmark { - display: none !important; - width: 0 !important; - min-width: 0 !important; - margin: 0 !important; - padding: 0 !important; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip .mdc-evolution-chip__checkmark-svg { - display: none !important; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip .mdc-evolution-chip__graphic { - display: none !important; - width: 0 !important; - min-width: 0 !important; - margin: 0 !important; - padding: 0 !important; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip .mdc-evolution-chip__action { - padding-left: 16px !important; - padding-right: 8px !important; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-standard-chip.mdc-evolution-chip--selected .mdc-evolution-chip__action { - padding-left: 16px !important; - padding-right: 8px !important; -} - -.saved-filters-tabs - ::ng-deep - .mat-mdc-standard-chip.mdc-evolution-chip--selected - .mdc-evolution-chip__action - .mdc-evolution-chip__action__content { - margin-left: 0 !important; - padding-left: 0 !important; - transform: translateX(0) !important; -} - -.saved-filters-tabs ::ng-deep .filter-chip-wrapper { - display: flex; - align-items: center; - gap: 4px; -} - -.saved-filters-tabs ::ng-deep .mat-mdc-chip-action { - display: flex; - align-items: center; -} - -.saved-filters-tabs ::ng-deep .mdc-evolution-chip__action { - display: flex; - align-items: center; - justify-content: flex-start; -} - -.saved-filters-tabs ::ng-deep .mdc-evolution-chip__text-label { - display: flex; - align-items: center; - line-height: 1; -} - -.filter-chip-content { - display: inline-block; - vertical-align: middle; - line-height: 1; -} - -.filter-chip-menu-button { - opacity: 1; - width: 24px; - height: 24px; - line-height: 24px; - flex-shrink: 0; - margin-left: auto; - margin-right: -4px; -} - -.filter-chip-menu-button mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -@media (prefers-color-scheme: light) { - .saved-filters-tabs ::ng-deep .mat-mdc-standard-chip { - --mat-chip-outline-color: rgba(0, 0, 0, 0.24); - --mat-chip-elevated-selected-container-color: var(--color-accentedPalette-500) !important; - --mat-chip-label-text-color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .saved-filters-tabs ::ng-deep .mat-mdc-standard-chip { - --mat-chip-outline-color: rgba(255, 255, 255, 0.24); - --mat-chip-elevated-selected-container-color: var(--color-accentedPalette-500) !important; - --mat-chip-label-text-color: rgba(255, 255, 255, 0.64); - } -} - -.filters-container { - display: flex; - align-items: center; - gap: 20px; - margin-top: -8px; - transform: translateX(-10%) scale(0.8); -} - -@media (width <= 600px) { - .filters-container { - display: flex; - align-items: center; - flex-direction: row; - flex-wrap: wrap; - gap: 6px 8px; - margin: 4px 0 0; - padding: 8px 16px; - border-radius: 10px; - background: rgba(0, 0, 0, 0.03); - transform: none; - max-width: 100%; - min-width: 0; - overflow-x: clip; - overflow-y: visible; - } - - .filters-container > .static-filters { - align-items: center; - min-height: 32px; - } - - - .dynamic-column-editor { - flex: 1 1 100%; - flex-direction: row; - align-items: center; - flex-wrap: wrap; - gap: 6px; - min-width: 0; - } - - .dynamic-column-editor > * { - min-width: 0; - } - - .dynamic-column-editor ::ng-deep .mat-mdc-form-field, - .dynamic-column-editor ::ng-deep .foreign-key { - flex: 1 1 100%; - } - - .dynamic-column-editor .column-name { - flex: 1 1 100%; - font-size: 12px; - line-height: 1.3; - gap: 6px; - margin: 0; - opacity: 0.6; - font-weight: 600; - white-space: nowrap; - } - - .dynamic-column-editor .column-name strong { - margin-left: 0; - font-size: 12px; - font-weight: 600; - color: inherit; - text-transform: none; - } - - .dynamic-column-editor .comparator-text { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 999px; - background: var(--color-accentedPalette-50); - color: var(--color-accentedPalette-700); - font-size: 12px; - font-weight: 500; - white-space: nowrap; - } - - .dynamic-column-editor ::ng-deep .foreign-key { - min-width: 0 !important; - flex: 1 1 auto; - margin-bottom: 0; - } - - .dynamic-column-editor ::ng-deep .mat-mdc-form-field { - flex: 1 1 auto; - min-width: 0; - width: auto; - } - - .dynamic-column-editor ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - .dynamic-column-editor ::ng-deep .mat-mdc-text-field-wrapper { - padding: 0 10px; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .dynamic-column-editor .column-name strong { - color: rgba(255, 255, 255, 0.87); - } - - .dynamic-column-editor .comparator-text { - background: var(--color-accentedPalette-900); - color: var(--color-accentedPalette-100); - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .filters-container { - background: rgba(255, 255, 255, 0.04); - } -} - -.filters-where-label { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 4px; - margin-top: -2px !important; - white-space: nowrap; -} - -@media (width <= 600px) { - .filters-where-label { - margin-top: 0 !important; - min-height: 32px; - align-items: center; - line-height: 1; - } -} - -.filters-where-label .column-name-icon { - font-size: 18px; - width: 18px; - height: 18px; - line-height: 18px; - vertical-align: middle; -} - -@media (prefers-color-scheme: light) { - .filters-where-label { - color: rgba(0, 0, 0, 0.87); - } -} - -@media (prefers-color-scheme: dark) { - .filters-where-label { - color: rgba(255, 255, 255, 0.87); - } -} - -.dynamic-column-editor { - display: flex; - align-items: center; - gap: 16px; - margin-left: 0; - /* transform: translateX(-20%) scale(0.8); */ - /* transform-origin: center right; */ -} - -.column-name { - margin-top: -4px !important; - display: flex; - align-items: center; - gap: 4px; - font-weight: 500; -} - -@media (width <= 600px) { - .column-name { - margin-top: 0 !important; - } -} - -.column-name-icon { - font-size: 18px; - width: 18px; - height: 18px; - line-height: 18px; - vertical-align: middle; -} - -.comparator-icon { - font-size: 18px; - width: 18px; - height: 18px; - margin-right: 8px; - vertical-align: middle; -} - -::ng-deep .mat-mdc-select-panel .mat-mdc-option { - display: flex; - align-items: center; -} - -.comparator-text { - display: none; -} - -@media (width <= 600px) { - .comparator-select { - display: none; - } - - .comparator-text { - display: inline; - } -} - -@media (prefers-color-scheme: light) { - .column-name { - color: rgba(0, 0, 0, 0.64); - } -} -@media (prefers-color-scheme: dark) { - .column-name { - color: rgba(255, 255, 255, 0.64); - } -} - -.column-name strong { - margin-left: 8px; -} - -.dynamic-column-editor ::ng-deep .foreign-key { - display: block; - min-width: 280px; - margin-bottom: 16px; -} - -.dynamic-column-editor ::ng-deep .foreign-key .mat-mdc-form-field-subscript-wrapper, -.dynamic-column-editor ::ng-deep .foreign-key .foreign-key__link { - display: none; -} - -.static-filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - transform: translateX(5%) scale(1.1); - padding-bottom: 16px; - margin-left: 0; -} - -@media (width <= 600px) { - .static-filters { - transform: none; - padding-bottom: 0; - } -} - -.static-filter-chip { - cursor: default; - pointer-events: none; -} - -::ng-deep .static-filter-chip .mat-mdc-chip { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - --mdc-chip-container-shape-radius: 16px !important; - background-color: #e8ecee !important; - border-radius: 16px !important; - pointer-events: none !important; - cursor: default !important; -} - -::ng-deep .static-filter-chip .mat-mdc-chip:hover { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; - cursor: default !important; -} - -::ng-deep .static-filter-chip .mat-mdc-chip:active { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; - cursor: default !important; -} - -::ng-deep .static-filter-chip .mdc-evolution-chip__cell { - background-color: #e8ecee !important; - border-radius: 16px !important; -} - -::ng-deep .static-filter-chip .mdc-evolution-chip__cell:hover { - background-color: #e8ecee !important; -} - -::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; - pointer-events: none !important; -} - -::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon:hover { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; - cursor: default !important; -} - -::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon:active { - --mdc-chip-container-color: #e8ecee !important; - --mdc-chip-elevated-container-color: #e8ecee !important; - background-color: #e8ecee !important; - cursor: default !important; -} - -::ng-deep .static-filter-chip .mdc-evolution-chip__action { - pointer-events: none !important; - cursor: default !important; -} - -@media (prefers-color-scheme: dark) { - ::ng-deep .static-filter-chip .mat-mdc-chip { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - --mdc-chip-label-text-color: rgba(255, 255, 255, 0.87) !important; - background-color: #3a4a5c !important; - color: rgba(255, 255, 255, 0.87) !important; - } - - ::ng-deep .static-filter-chip .mat-mdc-chip:hover { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mat-mdc-chip:active { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mdc-evolution-chip__cell { - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mdc-evolution-chip__cell:hover { - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon:hover { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } - - ::ng-deep .static-filter-chip .mat-mdc-chip.mdc-evolution-chip--with-primary-icon:active { - --mdc-chip-container-color: #3a4a5c !important; - --mdc-chip-elevated-container-color: #3a4a5c !important; - background-color: #3a4a5c !important; - } -} - -/* .dynamic-column-editor + .static-filters { - margin-top: -16px; -} */ diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.html b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.html deleted file mode 100644 index 66edd276f..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.html +++ /dev/null @@ -1,231 +0,0 @@ -
- -
- - - - - - - - {{ filter.name }} - - - - - - - - - - - - - - - - - -
- -
- - subdirectory_arrow_right - where - -
- - {{ savedFilterMap[selectedFilterSetId]?.dynamicColumn.column }} - - {{ {startswith: 'starts with', endswith: 'ends with', eq: 'equal', contains: 'contains', icontains: 'not contains', empty: 'is empty'}[savedFilterMap[selectedFilterSetId].dynamicColumn.operator] }} - - - {{ {eq: '=', gt: '>', lt: '<', gte: '≥', lte: '≤'}[savedFilterMap[selectedFilterSetId].dynamicColumn.operator] }} - - - - - - play_arrow - starts with - - - play_arrow - ends with - - - drag_handle - equal - - - search - contains - - - block - not contains - - - space_bar - is empty - - - - - - - - drag_handle - equal - - - keyboard_arrow_right - greater than - - - keyboard_arrow_left - less than - - - keyboard_double_arrow_right - greater than or equal - - - keyboard_double_arrow_left - less than or equal - - - - - -
-
- -
- - - - -
- - -
- -
-
-
- - {{ getFilter(filter) }} - -
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts deleted file mode 100644 index ebb68d033..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { SavedFiltersPanelComponent } from './saved-filters-panel.component'; - -// We need to mock the JsonURL import used in the component -class JsonURLMock { - static stringify(obj: any): string { - return JSON.stringify(obj); - } - - static parse(str: string): any { - try { - return JSON.parse(str); - } catch (_e) { - return {}; - } - } -} - -// Add to global scope to be used by the component -(window as any).JsonURL = JsonURLMock; - -describe('SavedFiltersPanelComponent', () => { - let component: SavedFiltersPanelComponent; - let fixture: ComponentFixture; - let _tablesServiceSpy: TablesService; - let _routerSpy: Router; - - const mockFilter = { - id: 'filter1', - name: 'Test Filter', - filters: { - name: { eq: 'John' }, - age: { gt: 25 }, - }, - dynamic_column: { - column_name: 'city', - comparator: 'eq', - }, - }; - - beforeEach(async () => { - const tablesServiceMock = { - getSavedFilters: vi.fn().mockReturnValue(of([mockFilter])), - createSavedFilter: vi.fn(), - cast: of({}), - }; - - const routerMock = { - navigate: vi.fn(), - }; - - const activatedRouteMock = { - queryParams: of({}), - paramMap: of(convertToParamMap({})), - queryParamMap: of(convertToParamMap({})), - snapshot: { - queryParams: {}, - paramMap: { - get: (_key: string) => null, - }, - queryParamMap: { - get: (_key: string) => null, - }, - }, - }; - - const matDialogMock = { - open: vi.fn(), - }; - - const connectionsServiceMock = { - get currentConnection() { - return { type: 'postgres' }; - }, - canEditConnection: () => false, - }; - - await TestBed.configureTestingModule({ - imports: [SavedFiltersPanelComponent, HttpClientTestingModule, Angulartics2Module.forRoot()], - providers: [ - { provide: TablesService, useValue: tablesServiceMock }, - { provide: ConnectionsService, useValue: connectionsServiceMock }, - { provide: Router, useValue: routerMock }, - { provide: ActivatedRoute, useValue: activatedRouteMock }, - { provide: MatDialog, useValue: matDialogMock }, - ], - }).compileComponents(); - - _tablesServiceSpy = TestBed.inject(TablesService); - _routerSpy = TestBed.inject(Router); - - fixture = TestBed.createComponent(SavedFiltersPanelComponent); - component = fixture.componentInstance; - component.connectionID = 'conn1'; - component.selectedTableName = 'users'; - component.structure = []; - component.tableTypes = {}; - component.selectedTableDisplayName = 'Users'; - component.tableForeignKeys = []; - - // Mock filterSelected event emitter - vi.spyOn(component.filterSelected, 'emit'); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should process filters data to separate static filters and dynamic column', () => { - const result = component.processFiltersData(mockFilter); - - expect(result.dynamicColumn).toBeTruthy(); - expect(result.dynamicColumn?.column).toBe('city'); - expect(result.dynamicColumn?.operator).toBe('eq'); - - expect(result.staticFilters.length).toBe(2); - expect(result.staticFilters[0].column).toBe('name'); - expect(result.staticFilters[1].column).toBe('age'); - }); - - it('should update dynamic column comparator', () => { - component.selectedFilterSetId = 'filter1'; - component.savedFilterMap = { - filter1: { - dynamicColumn: { column: 'city', operator: 'eq', value: 'New York' }, - }, - }; - - component.updateDynamicColumnComparator('contains'); - - expect(component.savedFilterMap.filter1.dynamicColumn.operator).toBe('contains'); - }); - - it('should set value to empty string when comparator is empty', () => { - // Setup component with the minimal required properties - component.selectedFilterSetId = 'filter1'; - component.savedFilterMap = { - filter1: { - dynamicColumn: { column: 'city', operator: 'eq', value: 'New York' }, - filters: { city: { eq: 'New York' } }, - }, - }; - - // Spy on applyDynamicColumnChanges to prevent it from executing - vi.spyOn(component, 'applyDynamicColumnChanges'); - - // Call the method under test - component.updateDynamicColumnComparator('empty'); - - // Verify the value was set to empty string - expect(component.savedFilterMap.filter1.dynamicColumn.value).toBe(''); - }); - - it('should update dynamic column value', () => { - // Setup component with the minimal required properties - component.selectedFilterSetId = 'filter1'; - component.savedFilterMap = { - filter1: { - dynamicColumn: { column: 'city', operator: 'eq', value: 'New York' }, - filters: { city: { eq: 'New York' } }, - }, - }; - - // Spy on applyDynamicColumnChanges to prevent it from executing - vi.spyOn(component, 'applyDynamicColumnChanges'); - - // Replace setTimeout with a function that executes immediately - vi.spyOn(window, 'setTimeout').mockImplementation((fn: TimerHandler) => { - // Execute function immediately instead of waiting - if (typeof fn === 'function') fn(); - // Return a fake timer ID - return 999 as unknown as ReturnType; - }); - - // Call the method under test - component.updateDynamicColumnValue('Chicago'); - - // Verify the value was updated - expect(component.savedFilterMap.filter1.dynamicColumn.value).toBe('Chicago'); - expect(component.applyDynamicColumnChanges).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts deleted file mode 100644 index 6facb3ea8..000000000 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import { Angulartics2OnModule } from 'angulartics2'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import posthog from 'posthog-js'; -import { PlaceholderSavedFiltersComponent } from 'src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component'; -import { UIwidgets as FilterUIwidgets, filterTypes } from 'src/app/consts/filter-types'; -import { UIwidgets as EditUIwidgets } from 'src/app/consts/record-edit-types'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TableField, TableForeignKey } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { SavedFiltersDialogComponent } from './saved-filters-dialog/saved-filters-dialog.component'; - -@Component({ - selector: 'app-saved-filters-panel', - imports: [ - CommonModule, - FormsModule, - DynamicModule, - SignalComponentIoModule, - MatButtonModule, - MatIconModule, - MatChipsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatTabsModule, - MatTooltipModule, - MatMenuModule, - PlaceholderSavedFiltersComponent, - Angulartics2OnModule, - ], - templateUrl: './saved-filters-panel.component.html', - styleUrl: './saved-filters-panel.component.css', -}) -export class SavedFiltersPanelComponent implements OnInit, OnDestroy { - protected posthog = posthog; - @Input() connectionID: string; - @Input() selectedTableName: string; - @Input() selectedTableDisplayName: string; - @Input() tableTypes: any; - @Input() structure: any; - @Input() tableForeignKeys: TableForeignKey[] = []; - @Input() tableWidgets: any = {}; - @Output() filterSelected = new EventEmitter(); - @Input() resetSelection: boolean = false; - - protected canEditConnection = () => this._connections.canEditConnection(); - - private dynamicColumnValueDebounceTimer: any = null; - - public savedFilterData = null; - public savedFilterMap: { [key: string]: any } = {}; - - public selectedFilterSetId: string | null = null; - public selectedFilter: any = null; - public shouldAutofocus: boolean = false; - public currentFilterForMenu: any = null; - - public tableStructure: any = null; - public tableRowFieldsShown: { [key: string]: any } = {}; - public tableRowStructure: { [key: string]: any } = {}; - public UIwidgets = { ...EditUIwidgets, ...FilterUIwidgets }; - - public displayedComparators = { - eq: '=', - gt: '>', - lt: '<', - gte: '>=', - lte: '<=', - startswith: 'starts with', - endswith: 'ends with', - contains: 'contains', - icontains: 'not contains', - empty: 'is empty', - }; - - constructor( - private dialog: MatDialog, - private _tables: TablesService, - private _connections: ConnectionsService, - private router: Router, - private route: ActivatedRoute, - ) {} - - ngOnInit() { - this.route.paramMap.subscribe((params) => { - const tableNameFromUrl = params.get('table-name'); - if (tableNameFromUrl) { - this.savedFilterData = null; - this.selectedFilterSetId = null; - this.selectedFilter = null; - this.selectedTableName = tableNameFromUrl; - this.loadSavedFilters(); - } - }); - - this.route.queryParamMap.subscribe((params) => { - const savedFilterId = params.get('saved_filter'); - if (savedFilterId) { - this.selectedFilterSetId = savedFilterId; - } else { - this.selectedFilterSetId = null; - } - }); - - this._tables.cast.subscribe((arg) => { - if (arg === 'filters set saved') { - this.loadSavedFilters(); - } - - if (arg === 'filters set updated') { - // Get the current saved filter ID from URL - const savedFilterIdFromUrl = this.route.snapshot.queryParams.saved_filter; - - if (savedFilterIdFromUrl) { - // If we have a filter selected in URL, get latest data and update URL - this._tables.getSavedFilters(this.connectionID, this.selectedTableName).subscribe({ - next: (data) => { - if (data) { - // Find the updated filter in the response - const updatedFilter = data.find((filter) => filter.id === savedFilterIdFromUrl); - if (updatedFilter) { - const processedFilter = this.processFiltersData(updatedFilter); - - // Update local state - this.selectedFilterSetId = savedFilterIdFromUrl; - this.filterSelected.emit(processedFilter); - - // Update URL with the refreshed filter data - const additionalParams: any = { - filters: JsonURL.stringify(updatedFilter.filters), - saved_filter: savedFilterIdFromUrl, - }; - - if (updatedFilter.dynamic_column) { - additionalParams.dynamic_column = JsonURL.stringify({ - column_name: updatedFilter.dynamic_column.column_name, - comparator: updatedFilter.dynamic_column.comparator, - }); - } - - const queryParams = this.buildQueryParams(additionalParams); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { queryParams }); - } - - // Update the full filters map - this.savedFilterData = data.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); - this.savedFilterMap = Object.assign( - {}, - ...data.map((filter) => { - const transformedFilter = this.processFiltersData(filter); - return { [filter.id]: transformedFilter }; - }), - ); - } - }, - error: (error) => { - console.error('Error fetching updated filters:', error); - this.loadSavedFilters(); - }, - }); - } else { - // Just refresh filters if no filter selected in URL - this.loadSavedFilters(); - } - } - - if (arg === 'delete saved filters') { - this.loadSavedFilters(); - this.selectedFilterSetId = null; - this.filterSelected.emit(null); - const queryParams = this.buildQueryParams(); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { queryParams }); - } - }); - - this.tableRowStructure = Object.assign( - {}, - ...this.structure.map((field: TableField) => { - return { [field.column_name]: field }; - }), - ); - } - - loadSavedFilters() { - if (!this.connectionID || !this.selectedTableName) { - return; - } - - this._tables.getSavedFilters(this.connectionID, this.selectedTableName).subscribe({ - next: (data) => { - if (data) { - this.savedFilterData = data.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - this.savedFilterMap = Object.assign( - {}, - ...data.map((filter) => { - const transformedFilter = this.processFiltersData(filter); - return { [filter.id]: transformedFilter }; - }), - ); - - const params = this.route.snapshot.queryParams; - const dynamicColumn = params.dynamic_column ? JsonURL.parse(params.dynamic_column) : null; - - if (this.selectedFilterSetId && this.savedFilterData.length > 0) { - if (dynamicColumn && this.savedFilterMap[this.selectedFilterSetId]) { - const filters = params.filters ? JsonURL.parse(params.filters) : {}; - - this.savedFilterMap[this.selectedFilterSetId].dynamicColumn = { - column: dynamicColumn.column_name, - operator: dynamicColumn.comparator, - value: filters[dynamicColumn.column_name]?.[dynamicColumn.comparator] || null, - }; - } - } - } - }, - error: (error) => { - console.error('Error fetching saved filters:', error); - }, - }); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes.resetSelection && this.resetSelection) { - this.selectedFilterSetId = null; - } - } - - ngOnDestroy() { - if (this.dynamicColumnValueDebounceTimer) { - clearTimeout(this.dynamicColumnValueDebounceTimer); - } - } - - handleOpenSavedFiltersDialog(filtersSet: any = null) { - const _dialogRef = this.dialog.open(SavedFiltersDialogComponent, { - width: '56em', - panelClass: 'mobile-bottom-sheet-dialog', - data: { - connectionID: this.connectionID, - tableName: this.selectedTableName, - displayTableName: this.selectedTableDisplayName, - structure: this.structure, - tableForeignKeys: this.tableForeignKeys, - tableWidgets: this.tableWidgets, - filtersSet: filtersSet - ? filtersSet - : { - name: '', - filters: {}, - }, - }, - }); - } - - setCurrentFilter(filter: any) { - this.currentFilterForMenu = filter; - } - - handleEditFilter(filter: any) { - if (filter) { - this.handleOpenSavedFiltersDialog(filter); - } - } - - handleDeleteFilter(filter: any) { - if (filter) { - this._tables.deleteSavedFilter(this.connectionID, this.selectedTableName, filter.id).subscribe({ - next: () => {}, - error: (error) => { - console.error('Error deleting filter:', error); - }, - }); - } - } - - getFilterEntries(filters: any): { column: string; operator: string; value: string }[] { - if (!filters) return []; - - const entries: { column: string; operator: string; value: string }[] = []; - - Object.keys(filters).forEach((column) => { - const operations = filters[column]; - Object.keys(operations).forEach((operator) => { - entries.push({ - column, - operator, - value: operations[operator], - }); - }); - }); - return entries; - } - - processFiltersData(filter: any) { - const transformedFilter = { - ...filter, - filterEntries: this.getFilterEntries(filter.filters), - staticFilters: [] as { column: string; operator: string; value: any }[], - dynamicColumn: null as { column: string; operator: string; value: any } | null, - }; - - if (filter.dynamic_column?.column_name) { - transformedFilter.dynamicColumn = { - column: filter.dynamic_column.column_name, - operator: filter.dynamic_column.comparator, - value: null, - }; - - const dynamicColFilters = filter.filters?.[filter.dynamic_column.column_name]; - if (dynamicColFilters) { - const operator = filter.dynamic_column.comparator; - transformedFilter.dynamicColumn.value = dynamicColFilters[operator]; - } - } - - transformedFilter.staticFilters = this.getFilterEntries(filter.filters).filter( - (entry) => !filter.dynamic_column || entry.column !== filter.dynamic_column.column_name, - ); - - return transformedFilter; - } - private buildQueryParams(additionalParams: any = {}): any { - const currentParams = this.route.snapshot.queryParams; - - // Start with pagination parameters - const queryParams: any = { - page_index: 0, - page_size: currentParams.page_size || 30, - ...additionalParams, - }; - - // Preserve sort parameters if present - if (currentParams.sort_active) { - queryParams.sort_active = currentParams.sort_active; - } - - if (currentParams.sort_direction) { - queryParams.sort_direction = currentParams.sort_direction; - } - - return queryParams; - } - - selectFiltersSet(selectedFilterSetId: string): void { - if (this.selectedFilterSetId === selectedFilterSetId) { - this.selectedFilterSetId = null; - this.filterSelected.emit(null); - this.shouldAutofocus = false; - const queryParams = this.buildQueryParams(); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { queryParams }); - } else { - this.selectedFilterSetId = selectedFilterSetId; - this.shouldAutofocus = true; - const selectedFilter = this.savedFilterMap[selectedFilterSetId]; - this.filterSelected.emit(selectedFilter); - - const additionalParams: any = { - filters: JsonURL.stringify(selectedFilter.filters), - saved_filter: selectedFilterSetId, - }; - - if (selectedFilter.dynamicColumn) { - additionalParams.dynamic_column = JsonURL.stringify({ - column_name: selectedFilter.dynamicColumn.column, - comparator: selectedFilter.dynamicColumn.operator, - }); - } - - const queryParams = this.buildQueryParams(additionalParams); - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { queryParams }); - - setTimeout(() => { - this.shouldAutofocus = false; - }, 500); - } - } - - selectFilter(entry: { column: string; operator: string; value: any }) { - this.selectedFilter = entry; - } - - getOperatorIcon(operator: string): string { - const iconMap: { [key: string]: string } = { - startswith: 'play_arrow', - endswith: 'play_arrow', - eq: 'drag_handle', - contains: 'search', - icontains: 'block', - empty: 'space_bar', - gt: 'keyboard_arrow_right', - lt: 'keyboard_arrow_left', - gte: 'keyboard_double_arrow_right', - lte: 'keyboard_double_arrow_left', - }; - return iconMap[operator] || 'drag_handle'; - } - - getFilter(activeFilter: { column: string; operator: string; value: any }) { - const displayedName = normalizeTableName(activeFilter.column); - const comparator = activeFilter.operator; - const filterValue = activeFilter.value; - if (comparator === 'startswith') { - return `${displayedName} = ${filterValue}...`; - } else if (comparator === 'endswith') { - return `${displayedName} = ...${filterValue}`; - } else if (comparator === 'contains') { - return `${displayedName} = ...${filterValue}...`; - } else if (comparator === 'icontains') { - return `${displayedName} != ...${filterValue}...`; - } else if (comparator === 'empty') { - return `${displayedName} = ' '`; - } else { - return `${displayedName} ${this.displayedComparators[comparator]} ${filterValue}`; - } - } - - get inputs() { - return filterTypes[this._connections.currentConnection.type]; - } - - isWidget(columnName: string) { - return this.tableWidgets && columnName in this.tableWidgets; - } - - getInputType(field: string) { - let widgetType; - if (this.isWidget(field)) { - widgetType = this.UIwidgets[this.tableWidgets[field].widget_type]?.type; - } else { - widgetType = this.inputs[this.tableTypes[field]]?.type; - } - return widgetType; - } - - getComparatorType(typeOfComponent) { - if (typeOfComponent === 'text') { - return 'text'; - } else if (typeOfComponent === 'number' || typeOfComponent === 'datetime') { - return 'number'; - } else { - return 'nonComparable'; - } - } - - trackByFn(_index: number, item: any) { - return item.key; - } - - updateDynamicColumnComparator = (comparator: string) => { - if (!this.savedFilterMap) { - console.error('savedFilterMap is undefined'); - return; - } - - if (!this.selectedFilterSetId) { - console.error('selectedFilterSetId is null or undefined'); - return; - } - - const selectedFilter = this.savedFilterMap[this.selectedFilterSetId]; - if (!selectedFilter) { - console.error(`No filter found with ID ${this.selectedFilterSetId} in savedFilterMap`); - return; - } - - if (!selectedFilter.dynamicColumn) { - console.error('dynamicColumn is null or undefined for the selected filter'); - return; - } - - selectedFilter.dynamicColumn.operator = comparator; - - if (comparator === 'empty') { - selectedFilter.dynamicColumn.value = ''; - } - }; - - updateDynamicColumnValue = (value: any) => { - if (!this.savedFilterMap) { - console.error('savedFilterMap is undefined'); - return; - } - - if (!this.selectedFilterSetId) { - console.error('selectedFilterSetId is null or undefined'); - return; - } - - const selectedFilter = this.savedFilterMap[this.selectedFilterSetId]; - if (!selectedFilter) { - console.error(`No filter found with ID ${this.selectedFilterSetId} in savedFilterMap`); - return; - } - - if (!selectedFilter.dynamicColumn) { - console.error('dynamicColumn is null or undefined for the selected filter'); - return; - } - - selectedFilter.dynamicColumn.value = value; - - if (this.dynamicColumnValueDebounceTimer) { - clearTimeout(this.dynamicColumnValueDebounceTimer); - } - - this.dynamicColumnValueDebounceTimer = setTimeout(() => { - this.applyDynamicColumnChanges(); - }, 800); - }; - - applyDynamicColumnChanges() { - if (!this.selectedFilterSetId) return; - - const selectedFilter = this.savedFilterMap[this.selectedFilterSetId]; - - if (!selectedFilter || !selectedFilter.dynamicColumn) return; - - const dynamicColumn = { - column_name: selectedFilter.dynamicColumn.column, - comparator: selectedFilter.dynamicColumn.operator, - }; - - const filterValue = - selectedFilter.dynamicColumn.value === '' || selectedFilter.dynamicColumn.value === undefined - ? null - : selectedFilter.dynamicColumn.value; - - const filters = { ...selectedFilter.filters }; - - if (selectedFilter.dynamicColumn.column && selectedFilter.dynamicColumn.operator && filterValue !== null) { - filters[selectedFilter.dynamicColumn.column] = { - [selectedFilter.dynamicColumn.operator]: filterValue, - }; - } else { - if (filters[selectedFilter.dynamicColumn.column]) { - delete filters[selectedFilter.dynamicColumn.column]; - } - } - - const additionalParams: any = { - filters: JsonURL.stringify(filters), - dynamic_column: JsonURL.stringify(dynamicColumn), - saved_filter: this.selectedFilterSetId, - }; - - const queryParams = this.buildQueryParams(additionalParams); - - const updatedFilterData = { - ...selectedFilter, - filters: filters, - dynamic_column: dynamicColumn, - }; - - updatedFilterData.filterEntries = this.getFilterEntries(filters); - - this.filterSelected.emit(updatedFilterData); - - this.router.navigate([`/dashboard/${this.connectionID}/${this.selectedTableName}`], { - queryParams, - }); - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-data-source.ts b/frontend/src/app/components/dashboard/db-tables-data-source.ts deleted file mode 100644 index fad0e2476..000000000 --- a/frontend/src/app/components/dashboard/db-tables-data-source.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { CollectionViewer } from '@angular/cdk/collections'; -import { DataSource } from '@angular/cdk/table'; -import { MatPaginator } from '@angular/material/paginator'; -import JSON5 from 'json5'; -import { filter } from 'lodash-es'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { catchError, finalize } from 'rxjs/operators'; -import { formatFieldValue } from 'src/app/lib/format-field-value'; -import { normalizeFieldName } from 'src/app/lib/normalize'; -import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; -import { Alert, AlertActionType, AlertType } from 'src/app/models/alert'; -import { CustomAction, CustomActionType, CustomEvent, TableField, TableForeignKey, Widget } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TablesService } from 'src/app/services/tables.service'; - -interface Column { - title: string; - normalizedTitle: string; - selected: boolean; -} - -interface RowsParams { - connectionID: string; - tableName: string; - sortColumn?: string; - requstedPage?: number; - pageSize?: number; - sortOrder?: 'ASC' | 'DESC'; - filters?: object; - comparators?: object; - search?: string; - isTablePageSwitched?: boolean; - uncached?: boolean; -} - -export class TablesDataSource implements DataSource { - private rowsSubject = new BehaviorSubject([]); - private loadingSubject = new BehaviorSubject(false); - - public loading$ = this.loadingSubject.asObservable(); - public paginator: MatPaginator; - - public structure; - public keyAttributes; - public columns: Column[] = []; - public dataColumns: string[]; - public dataNormalizedColumns: object; - public displayedColumns: string[]; - public displayedDataColumns: string[]; - public sortByColumns: string[]; - public defaultSort: { - column: string; - direction: 'asc' | 'desc'; - } | null; - public foreignKeysList: string[] = []; - public foreignKeys: TableForeignKey[] = []; - public widgetsList: string[]; - public widgets: { - [key: string]: Widget & { - widget_params: object; - }; - } = {}; - public widgetsCount: number = 0; - public selectWidgetsOptions: object; - public tableTypes: object = {}; - public relatedRecords = { - referenced_on_column_name: '', - referenced_by: [], - }; - public isExportAllowed: boolean; - public isImportAllowed: boolean; - public canDelete: boolean; - public isEmptyTable: boolean; - public tableActions: CustomAction[]; - public tableBulkActions: CustomAction[]; - public largeDataset: boolean; - public identityColumn: string; - - public alert_primaryKeysInfo: Alert; - public alert_settingsInfo: Alert; - public alert_widgetsWarning: Alert; - - constructor( - private _tables: TablesService, - _connections: ConnectionsService, - private _tableRow: TableRowService, - ) {} - - connect(_collectionViewer: CollectionViewer): Observable { - return this.rowsSubject.asObservable(); - } - - disconnect(_collectionViewer: CollectionViewer): void { - this.rowsSubject.complete(); - this.loadingSubject.complete(); - } - - formatRow(row, columns) { - const rowToFormat = {}; - for (const [columnName, columnStructute] of columns) { - let type = ''; - if (['number', 'tinyint'].includes(columnStructute.data_type) && columnStructute.character_maximum_length === 1) { - type = 'boolean'; - } else type = columnStructute.data_type; - rowToFormat[columnName] = formatFieldValue(row[columnName], type); - } - return rowToFormat; - } - - fetchRows({ - connectionID, - tableName, - requstedPage, - pageSize, - sortColumn, - sortOrder, - filters, - comparators, - search, - isTablePageSwitched, - uncached, - }: RowsParams) { - this.loadingSubject.next(true); - this.alert_primaryKeysInfo = null; - this.alert_settingsInfo = null; - this.alert_widgetsWarning = null; - - const fetchedTable = this._tables.fetchTable({ - connectionID, - tableName, - requstedPage: requstedPage + 1 || 1, - // chunkSize: this.paginator?.pageSize || 30, - chunkSize: pageSize || 30, - sortColumn, - sortOrder, - filters, - comparators, - search, - uncached, - }); - - if (fetchedTable) { - fetchedTable - .pipe( - catchError(() => of([])), - finalize(() => this.loadingSubject.next(false)), - ) - .subscribe((res: any) => { - if (res.rows?.length) { - const firstRow = res.rows[0]; - - this.foreignKeysList = res.foreignKeys.map((field) => { - return field.column_name; - }); - this.foreignKeys = Object.assign( - {}, - ...res.foreignKeys.map((foreignKey: TableForeignKey) => ({ [foreignKey.column_name]: foreignKey })), - ); - - this._tableRow - .fetchTableRow( - connectionID, - tableName, - res.primaryColumns.reduce((keys, column) => { - if (this.foreignKeysList.includes(column.column_name)) { - const referencedColumnNameOfForeignKey = - this.foreignKeys[column.column_name].referenced_column_name; - keys[column.column_name] = firstRow[column.column_name][referencedColumnNameOfForeignKey]; - } else { - keys[column.column_name] = firstRow[column.column_name]; - } - return keys; - }, {}), - ) - .subscribe((res) => (this.relatedRecords = res.referenced_table_names_and_columns[0])); - } - this.structure = [...res.structure]; - const columns = res.structure.reduce((items, item) => { - items.set(item.column_name, item); - return items; - }, new Map()); - - if (res.rows) { - this.isEmptyTable = res.rows.length === 0; - const formattedRows = res.rows.map((row) => this.formatRow(row, columns)); - this.rowsSubject.next(formattedRows); - } else { - this.isEmptyTable = true; - } - this.keyAttributes = res.primaryColumns; - this.identityColumn = res.identity_column; - this.tableActions = res.action_events; - this.tableBulkActions = res.action_events.filter( - (action: CustomEvent) => action.type === CustomActionType.Multiple, - ); - - if (res.widgets) { - this.widgetsList = res.widgets.map((widget: Widget) => { - return widget.field_name; - }); - this.widgetsCount = this.widgetsList.length; - this.widgets = Object.assign( - {}, - ...res.widgets.map((widget: Widget) => { - let parsedParams; - - try { - parsedParams = JSON5.parse(widget.widget_params); - } catch (_error) { - parsedParams = {}; - } - - return { - [widget.field_name]: { - ...widget, - widget_params: parsedParams, - }, - }; - }), - ); - } - - this.tableTypes = getTableTypes(res.structure, this.foreignKeysList); - - let orderedColumns: TableField[]; - if (res.table_settings.list_fields.length) { - orderedColumns = res.structure.sort( - (fieldA: TableField, fieldB: TableField) => - res.table_settings.list_fields.indexOf(fieldA.column_name) - - res.table_settings.list_fields.indexOf(fieldB.column_name), - ); - } else { - orderedColumns = [...res.structure]; - } - - if (isTablePageSwitched === undefined) - this.columns = orderedColumns - .filter((item) => item.isExcluded === false) - .map((item, index) => { - if (res.table_settings.columns_view && res.table_settings.columns_view.length !== 0) { - return { - title: item.column_name, - normalizedTitle: this.widgets[item.column_name]?.name || normalizeFieldName(item.column_name), - selected: res.table_settings.columns_view.includes(item.column_name), - }; - } else if (res.columns_view && res.columns_view.length !== 0) { - return { - title: item.column_name, - normalizedTitle: this.widgets[item.column_name]?.name || normalizeFieldName(item.column_name), - selected: res.columns_view.includes(item.column_name), - }; - } else { - if (index < 6) { - return { - title: item.column_name, - normalizedTitle: this.widgets[item.column_name]?.name || normalizeFieldName(item.column_name), - selected: true, - }; - } - return { - title: item.column_name, - normalizedTitle: this.widgets[item.column_name]?.name || normalizeFieldName(item.column_name), - selected: false, - }; - } - }); - - this.dataColumns = this.columns.map((column) => column.title); - this.dataNormalizedColumns = this.columns.reduce((normalizedColumns, column) => { - normalizedColumns[column.title] = column.normalizedTitle; - return normalizedColumns; - }, {}); - this.displayedDataColumns = filter(this.columns, (column) => column.selected === true).map( - (column) => column.title, - ); - if (this.keyAttributes.length) { - this.displayedColumns = ['select', ...this.displayedDataColumns, 'actions']; - } else { - this.displayedColumns = [...this.displayedDataColumns]; - this.alert_primaryKeysInfo = { - id: 10000, - type: AlertType.Info, - message: 'Add primary keys through your database to be able to work with the table rows.', - actions: [ - { - type: AlertActionType.Anchor, - caption: 'Instruction', - to: 'https://docs.rocketadmin.com/', - }, - ], - }; - } - - this.isExportAllowed = res.allow_csv_export; - this.isImportAllowed = res.allow_csv_import; - this.canDelete = res.can_delete; - - this.sortByColumns = res.sortable_by; - - // Only set defaultSort if both ordering_field and ordering are present - if (res.table_settings.ordering_field && res.table_settings.ordering) { - this.defaultSort = { - column: res.table_settings.ordering_field, - direction: res.table_settings.ordering.toLowerCase() as 'asc' | 'desc', - }; - } else { - this.defaultSort = null; - } - - const widgetsConfigured = res.widgets?.length; - if (!res.configured && !widgetsConfigured) - this.alert_settingsInfo = { - id: 10001, - type: AlertType.Info, - message: 'Configure now to reveal advanced table functionality and features.', - actions: [ - { - type: AlertActionType.Link, - caption: 'Configure all', - to: `/auto-configure/${connectionID}`, - }, - { - type: AlertActionType.Link, - caption: 'Settings', - to: 'settings', - }, - { - type: AlertActionType.Link, - caption: 'Fields display', - to: 'widgets', - }, - ], - }; - - this.largeDataset = res.large_dataset; - if (this.paginator) this.paginator.pageSize = res.pagination.perPage; - if (this.paginator) this.paginator.length = res.pagination.total; - }); - } - } - - changleColumnList(_connectionId: string, _tableName: string) { - this.displayedDataColumns = filter(this.columns, (column) => column.selected === true).map( - (column) => column.title, - ); - if (this.keyAttributes.length) { - this.displayedColumns = ['select', ...this.displayedDataColumns, 'actions']; - } else { - this.displayedColumns = [...this.displayedDataColumns]; - } - } - - getQueryParams(row, action) { - const params = Object.fromEntries( - this.keyAttributes.map((column) => { - if (this.foreignKeysList.includes(column.column_name)) { - const referencedColumnNameOfForeignKey = this.foreignKeys[column.column_name].referenced_column_name; - return [column.column_name, row[column.column_name][referencedColumnNameOfForeignKey]]; - } - return [column.column_name, row[column.column_name]]; - }), - ); - - if (action === 'dub') { - return { ...params, action: 'dub' }; - } else { - return params; - } - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.css b/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.css deleted file mode 100644 index a923dc1c6..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.css +++ /dev/null @@ -1,39 +0,0 @@ -.confirmation-message { - margin: 0 0 20px 0; -} - -.folder-info { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px 16px; - background-color: var(--color-infoPalette-50); - border-radius: 4px; - margin-bottom: 20px; -} - -.info-icon { - color: var(--color-infoPalette-500); - font-size: 20px; - width: 20px; - height: 20px; - margin-top: 2px; -} - -.info-text { - margin: 0; - font-size: 14px; - line-height: 1.5; - flex: 1; -} - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .folder-info { - background-color: var(--color-infoPalette-800); - } - - .info-text { - color: rgba(255, 255, 255, 0.72); - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.html b/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.html deleted file mode 100644 index 43d04eebb..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.html +++ /dev/null @@ -1,30 +0,0 @@ -

- Delete folder -

- - -

- Are you sure you want to delete the folder "{{data.folderName}}"? -

- -
- info -

- This folder contains {{data.tableCount}} table{{data.tableCount > 1 ? 's' : ''}}. - The tables will not be deleted, only removed from this folder. -

-
-
- - - - - \ No newline at end of file diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.ts b/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.ts deleted file mode 100644 index 4f1636b8e..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-delete-dialog/db-folder-delete-dialog.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; - -export interface DbFolderDeleteDialogData { - folderName: string; - tableCount: number; -} - -@Component({ - selector: 'app-db-folder-delete-dialog', - templateUrl: './db-folder-delete-dialog.component.html', - styleUrls: ['./db-folder-delete-dialog.component.css'], - standalone: true, - imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], -}) -export class DbFolderDeleteDialogComponent { - constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: DbFolderDeleteDialogData, - ) {} - - onCancel(): void { - this.dialogRef.close(false); - } - - onDelete(): void { - this.dialogRef.close(true); - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.css b/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.css deleted file mode 100644 index 6d960bb62..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.css +++ /dev/null @@ -1,213 +0,0 @@ -.dialog-body { - min-width: 400px; - max-height: 500px; - overflow-y: auto; -} - -.folder-name-section { - margin-bottom: 20px; - margin-top: 20px; - padding-bottom: 16px; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); -} - -.folder-name-field { - width: 100%; -} - -.folder-color-section { - margin-bottom: 20px; - padding-bottom: 16px; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); -} - -.folder-color-section h4 { - margin: 0 0 12px 0; - font-size: 14px; - font-weight: 500; - color: rgba(0, 0, 0, 0.64); -} - -.color-radio-group { - width: 100%; -} - -.color-row { - display: flex; - gap: 12px; - flex-wrap: wrap; - padding: 8px 0; -} - -.color-radio-label { - display: inline-block; - position: relative; - cursor: pointer; -} - -.color-radio-input { - position: absolute; - opacity: 0; - width: 0; - height: 0; -} - -.color-swatch { - width: 40px; - height: 40px; - border-radius: 50%; - border: 2px solid transparent; - cursor: pointer; - transition: all 0.2s ease; - position: relative; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.color-radio-label:hover .color-swatch { - transform: scale(1.1); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.2); -} - -.color-radio-label.selected .color-swatch { - /* border: 2px solid currentColor; */ - box-shadow: - 0 0 0 3px rgba(33, 33, 33, 0.1), - 0 3px 8px rgba(0, 0, 0, 0.2); - transform: scale(1.05); -} - -.color-radio-label.selected .color-swatch::after { - content: "✓"; - position: absolute; - /* font-size: 20px; */ - color: white; - /* text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); */ - /* font-weight: bold; */ -} - -.tables-section h4 { - margin: 0 0 12px 0; - font-size: 14px; - font-weight: 500; - color: rgba(0, 0, 0, 0.64); -} - -.table-list { - margin-top: 8px; -} - -.table-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 8px; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); - transition: background-color 0.2s ease; -} - -.table-item:hover { - background-color: rgba(0, 0, 0, 0.04); -} - -.table-item:last-child { - border-bottom: none; -} - -.table-name { - flex: 1; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding-right: 12px; -} - -.action-button { - min-width: 70px; - height: 32px; - font-size: 12px; - font-weight: 500; - border-radius: 4px; - transition: all 0.2s ease; -} - -.add-button { - color: var(--color-successPalette-500); - background-color: transparent; -} - -.add-button:hover { - background-color: var(--color-successPalette-50); -} - -.remove-button { - color: var(--color-warnPalette-500); - background-color: transparent; -} - -.remove-button:hover { - background-color: var(--color-warnPalette-50); -} - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .folder-name-section { - border-bottom-color: rgba(255, 255, 255, 0.12); - } - - .folder-color-section { - border-bottom-color: rgba(255, 255, 255, 0.12); - } - - .folder-color-section h4, - .tables-section h4 { - color: rgba(255, 255, 255, 0.72); - } - - .color-swatch { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - } - - .color-radio-label:hover .color-swatch { - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4); - } - - .color-radio-label.selected .color-swatch { - border-color: currentColor; - box-shadow: - 0 0 0 3px rgba(255, 255, 255, 0.1), - 0 3px 8px rgba(0, 0, 0, 0.4); - } - - .color-radio-label.selected .color-swatch::before { - border-color: currentColor; - opacity: 0.2; - } - - .table-item { - border-bottom-color: rgba(255, 255, 255, 0.12); - } - - .table-item:hover { - background-color: rgba(255, 255, 255, 0.04); - } - - .add-button { - color: var(--color-successDarkPalette-500); - } - - .add-button:hover { - background-color: var(--color-successDarkPalette-900); - } - - .remove-button { - color: var(--color-warnDarkPalette-500); - } - - .remove-button:hover { - background-color: var(--color-warnDarkPalette-900); - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.html b/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.html deleted file mode 100644 index d367d01c6..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.html +++ /dev/null @@ -1,70 +0,0 @@ -

- Edit folder -

- -
- - -
- - Folder name - - The "All Tables" folder cannot be renamed - - Folder name is required - - -
- - -
-

Folder icon color

-
-
- -
-
-
- -
-

Manage tables in this folder

-
-
- {{getTableName(table)}} - -
-
-
-
- - - - - -
diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.ts b/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.ts deleted file mode 100644 index 76b3e6f73..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-folder-edit-dialog/db-folder-edit-dialog.component.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { TableProperties } from 'src/app/models/table'; - -export interface Folder { - id: string; - name: string; - expanded: boolean; - tableIds: string[]; - iconColor?: string; - isEmpty?: boolean; -} - -export interface DbFolderEditDialogData { - folder: Folder; - tables: TableProperties[]; - folderIconColors: { name: string; value: string }[]; -} - -@Component({ - selector: 'app-db-folder-edit-dialog', - templateUrl: './db-folder-edit-dialog.component.html', - styleUrls: ['./db-folder-edit-dialog.component.css'], - standalone: true, - imports: [ - CommonModule, - MatDialogModule, - MatButtonModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - FormsModule, - ], -}) -export class DbFolderEditDialogComponent { - public folder: Folder; - public tables: TableProperties[]; - public folderIconColors: { name: string; value: string }[]; - originalFolderName: string; - - constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: DbFolderEditDialogData, - ) { - // Create a copy of the folder to work with - this.folder = { ...data.folder, tableIds: [...data.folder.tableIds] }; - // Store original name for validation - this.originalFolderName = data.folder.name; - // Set default color if not set - if (!this.folder.iconColor) { - this.folder.iconColor = '#757575'; - } - this.tables = data.tables; - this.folderIconColors = data.folderIconColors; - } - - getFolderIconColor(folder: Folder): string { - return folder.iconColor || '#757575'; - } - - onColorChange(color: string) { - console.log('Folder color changed to:', color); - } - - isTableInFolder(table: TableProperties): boolean { - return this.folder.tableIds.includes(table.table); - } - - getTableName(table: TableProperties): string { - return table.display_name || table.table; - } - - toggleTableInFolder(table: TableProperties) { - const tableId = table.table; - const isInFolder = this.folder.tableIds.includes(tableId); - - if (isInFolder) { - // Remove table from folder - this.folder.tableIds = this.folder.tableIds.filter((id) => id !== tableId); - } else { - // Add table to folder - this.folder.tableIds.push(tableId); - // If this was an empty folder, mark it as no longer empty - if (this.folder.isEmpty) { - this.folder.isEmpty = false; - } - } - - console.log('toggleTableInFolder'); - } - - isAllTablesFolder(): boolean { - return this.folder.id === '0' || this.folder.name === 'All Tables'; - } - - onSave() { - // Validate folder name - if (!this.folder.name || this.folder.name.trim() === '') { - return; // Don't save if name is empty - } - - // Trim the folder name before saving - this.folder.name = this.folder.name.trim(); - this.dialogRef.close(this.folder); - } - - onCancel() { - this.dialogRef.close(); - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css deleted file mode 100644 index c0d0821fe..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css +++ /dev/null @@ -1,959 +0,0 @@ -:host { - width: 240px; - max-width: 240px; - min-width: 240px; - display: block; - overflow-x: hidden; - overflow-y: auto; - position: relative; -} - -:host-context(.side-bar_collapsed) { - width: 65px; - max-width: 65px; - min-width: 65px; - overflow-x: hidden; - overflow-y: auto; -} - -/* Prevent horizontal scroll in sidebar */ -:host * { - max-width: 100%; - box-sizing: border-box; -} - -.collapsed-content { - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - min-height: calc(100vh - 120px); /* Adjust based on header height */ - position: relative; -} - -.collapsed-top-section { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - flex: 1; - justify-content: flex-start; - padding-top: 8px; - overflow-y: auto; - width: 100%; -} - -.collapsed-bottom-section { - display: flex; - flex-direction: column; - align-items: center; - position: sticky; - bottom: 0; - background-color: #ffffff; - padding: 8px 0; - width: 100%; - z-index: 10; -} - -.collapsed-folders { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -:host-context(.side-bar_collapsed) .collapsed-folders { - width: 100%; - justify-content: center; - align-items: center; -} - -.collapsed-folder-item { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; /* Restored to 32px for square shape */ - border-radius: 4px; - background-color: transparent; - color: var(--color-primaryPalette-50-contrast); - cursor: pointer; - transition: background-color 0.2s ease; -} - -.collapsed-folder-item:hover { - background-color: var(--color-primaryPalette-100); -} - -.collapsed-folder-item_active { - background-color: rgba(33, 33, 33, 0.1); -} - -.collapsed-folder-item_active:hover { - background-color: rgba(33, 33, 33, 0.15); -} - -.collapsed-folder-icon { - font-size: 20px; - height: 20px; - width: 20px; - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .collapsed-folder-icon { - color: rgba(255, 255, 255, 0.54); - } -} - -.collapsed-folder-icon.fallback { - font-size: 20px; - height: 20px; - width: 20px; -} - -/* Collapsed action buttons */ -.collapsed-search-button, -.collapsed-add-button { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s ease; - margin-bottom: 4px; -} - -.collapsed-search-button { - background-color: transparent; - color: var(--color-primaryPalette-500); -} - -.collapsed-add-button { - background-color: var(--color-primaryPalette-50); - color: var(--color-primaryPalette-50-contrast); -} - -.collapsed-search-button:hover { - background-color: rgba(0, 0, 0, 0.04); -} - -.collapsed-add-button:hover { - background-color: var(--color-primaryPalette-100); -} - -.collapsed-action-icon { - font-size: 18px; - height: 18px; - width: 18px; -} - -/* Collapsed table list styles */ -.collapsed-tables-list { - position: relative; - background: white; - border: 1px solid #e0e0e0; - border-top: none; - overflow: visible; - z-index: 1000; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px; - margin-top: 8px; - border-radius: 4px; - width: 48px; - min-width: 48px; -} - -:host-context(.side-bar_collapsed) .collapsed-tables-list { - margin-left: auto; - margin-right: auto; -} - -.collapsed-table-item { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; /* Restored to 32px for square shape */ - text-decoration: none; - color: inherit; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 6px; - flex-shrink: 0; - position: relative; -} - -.collapsed-table-item:hover { - transform: translateY(-1px); -} - -.collapsed-table-item:hover .collapsed-table-icon { - transform: scale(1.02); -} - -.collapsed-table-item_active { - background-color: rgba(33, 33, 33, 0.1); -} - -/* Dark theme adaptations */ -@media (prefers-color-scheme: dark) { - .collapsed-tables-list { - background: #303030; - border: 1px solid #424242; - } - - .collapsed-bottom-section { - background-color: #303030; - } - - .collapsed-folder-item { - background-color: transparent; - color: #ffffff; - } - - .collapsed-folder-item:hover { - background-color: rgba(255, 255, 255, 0.05); - } - - .collapsed-folder-item_active { - background-color: var(--surface-dark-color) !important; - color: #ffffff !important; - } - - .collapsed-folder-item_active .collapsed-folder-icon { - color: #ffffff !important; - } - - .collapsed-folder-item_active:hover, - .collapsed-folder-item_active:focus, - .collapsed-folder-item_active:active { - background-color: #000000 !important; - } - - /* Гарантируем фон для активной папки во всех состояниях */ - .collapsed-folder-item_active:not(:hover):not(:focus):not(:active) { - background-color: var(--surface-dark-color) !important; - } - - .collapsed-table-item { - color: #e0e0e0; - } - - .collapsed-table-item:hover { - background-color: rgba(255, 255, 255, 0.05); - } - - .collapsed-table-item_active { - background-color: var(--surface-dark-color); - color: #ffffff; - } - - .collapsed-table-item_active:hover { - background-color: #000000; - } - - .collapsed-search-button { - color: #ffffff !important; - } - - .collapsed-search-button:hover { - background-color: rgba(255, 255, 255, 0.1); - } - - .collapsed-add-button { - background-color: #424242; - color: #ffffff; - } - - .collapsed-add-button:hover { - background-color: #555555; - } -} - -.collapsed-table-item_active:hover { - background-color: rgba(33, 33, 33, 0.25); -} - -.collapsed-table-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: inherit; -} - -.collapsed-table-text { - font-size: 11px; - font-weight: 600; - text-align: center; - letter-spacing: 0.5px; - text-transform: uppercase; -} - -.collapsed-table-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.mat-icon-button { - margin-right: -8px; -} - -.mat-line { - line-height: 48px; -} - -.tables-list { - width: 100%; - box-sizing: border-box; - /* min-height: 0; */ - height: calc(100vh - 100px); /* Adjust based on header height */ -} - -.tables-list ::ng-deep .table-list-item .mat-list-item-content { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.table-list-item { - cursor: pointer; - transition: opacity 0.2s ease; -} - -.table-list-item[draggable="true"]:hover { - opacity: 0.8; -} - -.table-list-item[draggable="true"]:active { - opacity: 0.6; -} - -.table-list-initials { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 4px; - background-color: var(--color-primaryPalette-50); - color: var(--color-primaryPalette-50-contrast); -} - -@media (prefers-color-scheme: dark) { - .table-list-initials { - background-color: var(--color-whitePalette-900); - color: var(--color-whitePalette-900-contrast); - } - - .folder-header.expanded { - background-color: var(--surface-dark-color) !important; - color: #ffffff !important; - } - - .folder-header .folder-icon { - color: #ffffff !important; - } - - /* .folder-header.expanded .folder-name, */ - .folder-header .folder-name { - color: #ffffff !important; - } - - .folder-header.expanded .folder-expand-icon { - color: #ffffff !important; - } - - .folder-header:hover { - background-color: rgba(255, 255, 255, 0.05) !important; - } -} - -.table-list-initials:hover { - background-color: var(--color-primaryPalette-100); -} - -@media (prefers-color-scheme: dark) { - .table-list-initials:hover { - background-color: var(--color-whitePalette-900); - } -} - -.table-list-icon { - font-size: 20px; - height: 20px; - width: 20px; -} - -.tables-list ::ng-deep .mat-list-base .mat-list-item { - height: 40px !important; -} - -.list-item_active ::ng-deep .mdc-list-item__primary-text { - color: var(--color-accentedPalette-500); -} - -.list-item_active ::ng-deep .table-list-initials { - background-color: var(--color-accentedPalette-500); - color: var(--color-accentedPalette-500-contrast); -} - -.empty-message { - margin-top: 20px; - margin-left: 16px; -} - -/* Expanded state container */ -.expanded-container { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - position: relative; - overflow-y: auto; -} - -.search-input { - background-color: transparent; - margin-top: 8px; - margin-left: 16px; - margin-bottom: -8px; - width: calc(100% - 32px); - max-width: calc(240px - 32px); - box-sizing: border-box; - order: 1; /* Always first */ -} - -/* Add folder button container */ -.add-folder-button-container { - order: 2; /* Default: after search when no custom folders */ - margin-left: 16px; - margin-right: 16px; - margin-top: 4px; - margin-bottom: 8px; - width: calc(100% - 32px); - box-sizing: border-box; -} - -/* When custom folders exist, move add button to bottom */ -.expanded-container.has-custom-folders .add-folder-button-container { - order: 4; /* After folders section */ - position: sticky; - bottom: 0; - background-color: #ffffff; - padding: 8px 16px; - z-index: 10; - margin-top: auto; -} - -/* Dark theme support for add button container when at bottom */ -@media (prefers-color-scheme: dark) { - .expanded-container.has-custom-folders .add-folder-button-container { - background-color: #303030; - } -} - -.search-input ::ng-deep * { - background-color: transparent !important; -} - -.search-input ::ng-deep .mdc-text-field { - padding: 0 !important; -} - -.search-input ::ng-deep .mat-mdc-form-field-infix { - min-height: 0; -} - -.search-input - ::ng-deep - .mdc-text-field--no-label:not(.mdc-text-field--outlined):not(.mdc-text-field--textarea) - .mat-mdc-form-field-infix { - padding-top: 8px; - padding-bottom: 8px; -} - -.add-folder-button { - width: 100%; -} - -.add-folder-button:hover { - border-color: #757575 !important; - background-color: rgba(0, 0, 0, 0.04) !important; -} - -.add-folder-button mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.folders-section { - margin: 0 16px; - margin-bottom: 8px; - width: calc(100% - 32px); - box-sizing: border-box; - order: 3; /* After search and (optionally) add button */ - flex: 1; - overflow-y: auto; -} - -.folder-item { - margin-bottom: 4px; - border: none; - border-radius: 4px; - background: transparent; - width: 100%; - box-sizing: border-box; - position: relative; - transition: background-color 0.2s ease; -} - -.folder-item.drag-over { - background-color: rgba(33, 150, 243, 0.1); - border: 2px dashed #2196f3; -} - -.folder-item.expanded::after { - content: ""; - position: absolute; - bottom: 0; - left: 12px; - right: 12px; - height: 1px; - background-color: #e0e0e0; -} - -.folder-header { - display: flex; - align-items: center; - padding: 8px 4px 8px 0; - cursor: pointer; - user-select: none; - height: 40px; -} - -.folder-header:hover { - background: #f5f5f5; -} - -.folder-header.expanded { - background-color: var(--color-primaryPalette-50); - color: var(--color-primaryPalette-50-contrast); - border-radius: 4px; -} - -.folder-header.expanded .folder-icon { - color: var(--color-primaryPalette-50-contrast); -} - -.folder-header.expanded .folder-expand-icon { - color: var(--color-primaryPalette-50-contrast); -} - -.folder-actions { - display: flex; - align-items: center; - opacity: 0; - transition: opacity 0.2s ease; -} - -.folder-header:hover .folder-actions { - opacity: 1; -} - -.more-folder-button { - line-height: 18px; -} - -.more-folder-button mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.folder-name { - flex: 1; - font-weight: 500; - color: #424242; -} - -.folder-name-edit-container { - display: flex; - align-items: center; - flex: 1; - max-width: calc(100% - 40px); -} - -.folder-icon { - font-size: 20px; - width: 20px; - height: 20px; - color: rgba(0, 0, 0, 0.87); - margin-right: 8px; - margin-left: 8px; - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .folder-icon { - color: rgba(255, 255, 255, 0.54); - } -} - -.folder-expand-icon { - transition: transform 0.2s ease; - font-size: 20px; - width: 20px; - height: 20px; - color: #666; - margin-left: auto; -} - -.folder-expand-icon.expanded { - transform: rotate(90deg); -} - -.folder-tables { - padding: 0 12px 8px; - border-top: none; - width: 100%; - max-width: 240px; - box-sizing: border-box; - min-height: 40px; -} - -.folder-tables-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0; - margin-bottom: 4px; -} - -.tables-count { - font-size: 12px; - color: #666; - font-weight: 500; -} - -.add-table-button { - width: 24px; - height: 24px; - line-height: 24px; - border: none; - border-radius: 0; - background-color: transparent !important; - color: #9e9e9e !important; -} - -.add-table-button:hover { - border: none !important; - background-color: rgba(0, 0, 0, 0.02) !important; - color: #757575 !important; -} - -.add-table-button mat-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.selected-tables-list { - max-height: none; - overflow-y: visible; -} - -.selected-table-item { - display: flex; - align-items: center; - padding: 2px 0 2px 8px; - margin-bottom: 0; - background: transparent; - border-radius: 0; - font-size: 14px; - width: 100%; - box-sizing: border-box; - overflow: hidden; -} - -.table-link { - flex: 1; - display: flex; - align-items: center; - text-decoration: none; - color: inherit; - overflow: hidden; - min-width: 0; -} - -.table-link:hover { - background-color: rgba(0, 0, 0, 0.04); - border-radius: 0; - margin: 0 -12px; - padding: 0 12px; -} - -.table-link_active { - border-radius: 4px; - margin: 0 -12px; - padding: 0 12px; -} - -.table-link_active .table-name { - color: var(--color-accentedPalette-500); -} - -.table-name { - flex: 1; - font-weight: 400; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - display: block; - padding: 4px 0 4px 0; -} - -.remove-table-button { - width: 20px; - height: 20px; - line-height: 20px; - opacity: 0.4; - flex-shrink: 0; - border: none; - border-radius: 0; - background-color: transparent !important; - color: #9e9e9e !important; - margin-left: auto; - margin-right: 0; -} - -.remove-table-button:hover { - opacity: 0.7; - border: none !important; - background-color: transparent !important; - color: #757575 !important; -} - -.remove-table-button mat-icon { - font-size: 14px; - width: 14px; - height: 14px; -} - -.no-tables-message { - text-align: center; - padding: 16px 8px; - color: #666; - height: 200px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -.no-tables-message p { - margin: 0 0 12px 0; - font-size: 14px; -} - -.no-tables-message button { - font-size: 12px; - border-color: #e0e0e0 !important; - color: #9e9e9e !important; - background-color: transparent !important; -} - -.no-tables-message button:hover { - border-color: #bdbdbd !important; - background-color: rgba(0, 0, 0, 0.02) !important; - color: #757575 !important; -} - -/* Styles for empty folders */ -.no-tables-message.empty-folder-message { - padding: 12px 20px; - height: auto; - min-height: auto; -} - -.empty-folder-button { - border-style: dashed !important; - border-width: 2px !important; - border-color: #ccc !important; - background-color: transparent !important; - color: #666 !important; - font-size: 14px !important; - min-width: 200px !important; - padding: 20px 16px !important; -} - -.empty-folder-button:hover { - border-color: #2196f3 !important; - color: #2196f3 !important; - background-color: rgba(33, 150, 243, 0.04) !important; -} - -/* Folder menu alignment */ -::ng-deep .mat-menu-panel .mat-menu-item { - display: flex !important; - align-items: center !important; - gap: 12px !important; - min-height: 48px !important; -} - -::ng-deep .mat-menu-panel .mat-menu-item .material-symbols-outlined { - font-size: 20px !important; - width: 20px !important; - height: 20px !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - line-height: 1 !important; -} - -::ng-deep .mat-menu-panel .mat-menu-item span { - display: flex !important; - align-items: center !important; -} - -/* Add Table Dialog Styles */ -.edit-tables-dialog { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - opacity: 1; - visibility: visible; - transition: - opacity 0.2s ease, - visibility 0.2s ease; - outline: none; -} - -.dialog-content { - background: white; - border-radius: 8px; - max-width: 400px; - width: 90%; - max-height: 500px; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); - transform: scale(1); - transition: transform 0.2s ease; - position: relative; -} - -.dialog-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid #e0e0e0; -} - -.dialog-header h3 { - margin: 0; - font-size: 18px; - font-weight: 500; -} - -.dialog-header .close-button { - color: #666; - width: 32px; - height: 32px; - line-height: 32px; -} - -.dialog-header .close-button:hover { - color: #333; - background-color: rgba(0, 0, 0, 0.04); -} - -.dialog-header .close-button mat-icon { - font-size: 20px; - width: 20px; - height: 20px; -} - -.dialog-body { - padding: 16px 20px; - max-height: 300px; - overflow-y: auto; -} - -.available-table-item { - padding: 8px 0; - border-bottom: 1px solid #f0f0f0; -} - -.available-table-item:last-child { - border-bottom: none; -} - -/* CDK Drag-Drop reordering styles */ -.table-drag-handle { - color: #bdbdbd; - cursor: grab; - font-size: 16px; - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 2px; - transition: color 0.2s ease; -} - -.selected-table-item:hover .table-drag-handle { - color: #757575; -} - -.cdk-drag-preview.selected-table-item { - box-sizing: border-box; - border-radius: 4px; - box-shadow: - 0 5px 5px -3px rgba(0, 0, 0, 0.2), - 0 8px 10px 1px rgba(0, 0, 0, 0.14), - 0 3px 14px 2px rgba(0, 0, 0, 0.12); - background: #ffffff; - opacity: 0.9; -} - -.cdk-drag-placeholder { - opacity: 0.3; -} - -.cdk-drag-animating { - transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); -} - -.selected-tables-list.cdk-drop-list-dragging .selected-table-item:not(.cdk-drag-placeholder) { - transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); -} - -@media (prefers-color-scheme: dark) { - .table-drag-handle { - color: #616161; - } - - .selected-table-item:hover .table-drag-handle { - color: #9e9e9e; - } - - .cdk-drag-preview.selected-table-item { - background: #424242; - } -} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html deleted file mode 100644 index c9270f948..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - -
- - -
- -
- -
- search -
- - -
-
- dehaze - folder -
-
- - dehaze - - - -
- - -
- -
- add -
-
-
- - -
- - - - Nothing found. - - - -
- -
- - -
- -
-
- - dehaze - folder - -
- {{folder.name}} -
- -
- - - - - chevron_right - - - - - -
-
-
-
-
- drag_indicator - - {{getTableName(table)}} - -
- -
- -
-

No tables in this folder

- -
-
-
-
-
-
-
- -
- - - -

- No tables in this connection. -

- -

- Rocketadmin do not provide adding and deleting database tables, only editing ones. You can add a table with SQL editor. -

-
- - -
diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.spec.ts b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.spec.ts deleted file mode 100644 index 5ee455027..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { DbTablesListComponent } from './db-tables-list.component'; - -describe('DbTablesListComponent', () => { - let component: DbTablesListComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - providers: [provideHttpClient()], - imports: [Angulartics2Module.forRoot(), DbTablesListComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTablesListComponent); - component = fixture.componentInstance; - component.tableFolders = [ - { - category_id: 'all-tables-kitten', - category_name: 'All Tables', - tables: [], - }, - ]; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts deleted file mode 100644 index 214853121..000000000 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, inject, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatListModule } from '@angular/material/list'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TableCategory } from 'src/app/models/connection'; -import { TableProperties } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; -import { - DbFolderDeleteDialogComponent, - DbFolderDeleteDialogData, -} from './db-folder-delete-dialog/db-folder-delete-dialog.component'; -import { - DbFolderEditDialogComponent, - DbFolderEditDialogData, -} from './db-folder-edit-dialog/db-folder-edit-dialog.component'; - -export interface Folder { - id: string; - name: string; - expanded: boolean; - tableIds: string[]; - iconColor?: string; // Optional color for folder icon - isEmpty?: boolean; // Flag to indicate if folder is newly created and empty -} - -interface Category { - category_id: string; - category_name: string; - category_color?: string; - tables: TableProperties[]; -} - -@Component({ - selector: 'app-db-tables-list', - templateUrl: './db-tables-list.component.html', - styleUrls: ['./db-tables-list.component.css'], - imports: [ - CommonModule, - FormsModule, - DragDropModule, - MatButtonModule, - MatCheckboxModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatListModule, - MatTooltipModule, - MatMenuModule, - MatDialogModule, - RouterModule, - ContentLoaderComponent, - ], -}) -export class DbTablesListComponent implements OnInit, OnChanges { - @Input() connectionID: string; - @Input() connectionTitle: string; - @Input() tableFolders: Category[]; - @Input() selectedTable: string; - @Input() collapsed: boolean; - @Input() uiSettings: any; - - @Output() expandSidebar = new EventEmitter(); - - private _connections = inject(ConnectionsService); - protected canEditConnection = () => this._connections.canEditConnection(); - - public tableCategories: TableCategory[] = []; - public substringToSearch: string; - public foundTables: TableProperties[]; - public folders: Folder[] = []; - - // Dialog state - public currentFolder: Folder | null = null; - - // Drag and drop state - public draggedTable: TableProperties | null = null; - public dragOverFolder: string | null = null; - - // Collapsed state - public showCollapsedTableList: boolean = false; - public currentCollapsedFolder: Folder | null = null; - - // State preservation - private preservedFolderStates: { [key: string]: boolean } = {}; - private preservedActiveFolder: string | null = null; - - // Search state preservation - private preSearchFolderStates: { [key: string]: boolean } | null = null; - - // Folder icon colors - public folderIconColors = [ - { name: 'Default', value: 'rgba(0, 0, 0, 0.87)' }, - { name: 'Blue', value: '#2196F3' }, - { name: 'Green', value: '#4CAF50' }, - { name: 'Orange', value: '#FF9800' }, - { name: 'Red', value: '#F44336' }, - { name: 'Purple', value: '#9C27B0' }, - { name: 'Teal', value: '#009688' }, - { name: 'Pink', value: '#E91E63' }, - ]; - - constructor( - private _tableState: TableStateService, - private _tablesService: TablesService, - private _uiSettingsService: UiSettingsService, - private dialog: MatDialog, - ) {} - - ngOnInit() { - this.foundTables = this.tableFolders - .find((folder: any) => folder.category_id === 'all-tables-kitten') - ?.tables.map((table: TableProperties) => { - return { - table: table.table, - display_name: table.display_name, - normalizedTableName: normalizeTableName(table.table), - permissions: table.permissions, - }; - }); - this.folders = this.tableFolders.map((category) => { - const tableIds = category.tables.map((table: TableProperties) => table.table); - return { - id: category.category_id, - name: category.category_name, - expanded: category.category_id === 'all-tables-kitten' ? true : false, - tableIds, - iconColor: category.category_color, - }; - }); - - const expandedFolders = this.uiSettings?.tableFoldersExpanded || ['0']; - if (expandedFolders && expandedFolders.length > 0) { - this.folders.forEach((folder) => { - folder.expanded = expandedFolders.includes(folder.id); - }); - } - - // If no custom folders exist, always keep "All tables" expanded - if (!this.hasCustomFolders()) { - const allTablesFolder = this.folders.find((folder) => folder.id === 'all-tables-kitten'); - if (allTablesFolder) { - allTablesFolder.expanded = true; - } - } - - console.log('ngOnInit - showCollapsedTableList initialized to:', this.showCollapsedTableList); - } - - ngOnChanges(changes: SimpleChanges) { - if (changes.collapsed) { - if (changes.collapsed.currentValue === true) { - // Sidebar is being collapsed - preserve current state - this.preserveFolderStates(); - } else if (changes.collapsed.currentValue === false) { - // Sidebar is being expanded - restore preserved state - this.restoreFolderStates(); - } - } - } - - searchSubstring() { - if (!this.substringToSearch || this.substringToSearch.trim() === '') { - this.foundTables = this.allTables; - // Restore folder expanded states from before the search - if (this.preSearchFolderStates) { - this.folders.forEach((folder) => { - if (Object.hasOwn(this.preSearchFolderStates, folder.id)) { - folder.expanded = this.preSearchFolderStates[folder.id]; - } - }); - this.preSearchFolderStates = null; - } - return; - } - - // Save folder states before the first search modification - if (!this.preSearchFolderStates) { - this.preSearchFolderStates = {}; - this.folders.forEach((folder) => { - this.preSearchFolderStates[folder.id] = folder.expanded; - }); - } - - const searchTerm = this.substringToSearch.toLowerCase(); - - // Filter all tables by search term - this.foundTables = this.allTables.filter( - (tableItem) => - tableItem.table.toLowerCase().includes(searchTerm) || - tableItem.display_name?.toLowerCase().includes(searchTerm) || - tableItem.normalizedTableName?.toLowerCase().includes(searchTerm), - ); - - // Expand all folders that contain matching tables - this.folders.forEach((folder) => { - const folderTables = this.getFolderTables(folder); - const hasMatchingTables = folderTables.some((table) => - this.foundTables.some((foundTable) => foundTable.table === table.table), - ); - - if (hasMatchingTables) { - folder.expanded = true; - } else { - folder.expanded = false; - } - }); - } - - getTableName(table: TableProperties) { - return table.display_name || table.normalizedTableName || table.table; - } - - getTableNameLength(tableName: string) { - return tableName.length; - } - - closeSidebar() { - this._tableState.clearSelection(); - this._tableState.closeAIpanel(); - } - - onAddFolder() { - const newFolder: Folder = { - id: this.generateFolderId(), - name: `Folder ${this.folders.length}`, - expanded: true, // Разворачиваем папку сразу после создания - tableIds: [], - isEmpty: true, // Mark as empty for special styling - }; - this.folders.push(newFolder); - console.log('onAddFolder'); - this.saveFolders(); - } - - toggleFolder(folderId: string) { - const folder = this.folders.find((f) => f.id === folderId); - if (folder) { - // Prevent collapsing "All tables" when no custom folders exist - if (folder.id === 'all-tables-kitten' && folder.expanded && !this.hasCustomFolders()) { - return; - } - folder.expanded = !folder.expanded; - const expandedFolders = this.folders.filter((f) => f.expanded).map((f) => f.id); - this._uiSettingsService.updateConnectionSetting(this.connectionID, 'tableFoldersExpanded', expandedFolders); - } - } - - onCollapsedFolderClick(folder: Folder) { - console.log('Clicked on folder:', folder.name); - - // If clicking on the same folder that's already open, close it - if (this.currentCollapsedFolder?.id === folder.id) { - this.showCollapsedTableList = false; - this.currentCollapsedFolder = null; - } else { - // If clicking on a different folder, open it immediately - this.showCollapsedTableList = true; - this.currentCollapsedFolder = folder; - } - } - - getCollapsedTableList(): TableProperties[] { - if (!this.currentCollapsedFolder) { - console.log('No current collapsed folder'); - return []; - } - - const tables = this.getFolderTables(this.currentCollapsedFolder); - console.log('getCollapsedTableList - tables:', tables); - return tables; - } - - navigateToTable(_table: TableProperties) { - // This method is called when clicking on a table in collapsed mode - // The actual navigation is handled by routerLink in the template - // We just need to close the sidebar after navigation - this.closeSidebar(); - } - - toggleCollapsedSearch() { - // Open the sidebar and activate search - this.expandSidebar.emit(); - // Focus on search input after sidebar expands - setTimeout(() => { - const input = document.querySelector('.search-input input') as HTMLInputElement; - if (input) { - input.focus(); - input.select(); - } - }, 300); // Wait for sidebar animation to complete - } - - deleteFolder(folder: Folder) { - const dialogData: DbFolderDeleteDialogData = { - folderName: folder.name, - tableCount: folder.tableIds.length, - }; - - const dialogRef = this.dialog.open(DbFolderDeleteDialogComponent, { - width: '24em', - data: dialogData, - panelClass: 'db-folder-delete-dialog', - }); - - dialogRef.afterClosed().subscribe((confirmed: boolean) => { - if (confirmed) { - const index = this.folders.findIndex((f) => f.id === folder.id); - if (index > -1) { - this.folders.splice(index, 1); - this.saveFolders(); - } - } - }); - } - - getFolderTables(folder: Folder): TableProperties[] { - // Preserve folder.tableIds order by mapping through tableIds - const allTablesMap = new Map(this.allTables.map((t) => [t.table, t])); - const folderTables = folder.tableIds - .map((tableId) => allTablesMap.get(tableId)) - .filter((table): table is TableProperties => !!table); - - // If there's a search term, filter the collection tables too - if (this.substringToSearch && this.substringToSearch.trim() !== '') { - const searchTerm = this.substringToSearch.toLowerCase(); - return folderTables.filter( - (table) => - table.table.toLowerCase().includes(searchTerm) || - table.display_name?.toLowerCase().includes(searchTerm) || - table.normalizedTableName?.toLowerCase().includes(searchTerm), - ); - } - - return folderTables; - } - - getAvailableTables(folder: Folder | null): TableProperties[] { - if (!folder) return []; - return this.allTables.filter((table) => !folder.tableIds.includes(table.table)); - } - - isTableInAnyFolder(table: TableProperties): boolean { - return this.folders.some((folder) => folder.tableIds.includes(table.table)); - } - - shouldShowFolder(folder: Folder): boolean { - // If no search term, show all folders - if (!this.substringToSearch || this.substringToSearch.trim() === '') { - return true; - } - - // Show collection if it has tables matching the search - const matchingTables = this.getFolderTables(folder); - return matchingTables.length > 0; - } - - showEditTablesDialog(folder: Folder) { - // Expand the folder if it's collapsed - folder.expanded = true; - - const dialogData: DbFolderEditDialogData = { - folder: folder, - tables: this.allTables, - folderIconColors: this.folderIconColors, - }; - - const dialogRef = this.dialog.open(DbFolderEditDialogComponent, { - width: '32em', - data: dialogData, - panelClass: 'db-folder-edit-dialog', - }); - - dialogRef.afterClosed().subscribe((result: Folder | undefined) => { - if (result) { - // Update the folder with the result from dialog - const index = this.folders.findIndex((f) => f.id === result.id); - if (index !== -1) { - this.folders[index] = result; - this.saveFolders(); - } - } - }); - } - - get allTables(): TableProperties[] { - return this.tableFolders?.find((folder: any) => folder.category_id === 'all-tables-kitten')?.tables || []; - } - - isTableInFolder(table: TableProperties): boolean { - return this.currentFolder ? this.currentFolder.tableIds.includes(table.table) : false; - } - - isTableInCurrentDraggedFolder(table: TableProperties, folder: Folder): boolean { - return this.draggedTable && this.draggedTable.table === table.table && folder.tableIds.includes(table.table); - } - - toggleTableInFolder(table: TableProperties) { - if (!this.currentFolder) return; - - const tableId = table.table; - const isInFolder = this.currentFolder.tableIds.includes(tableId); - - if (isInFolder) { - // Remove from folder - this.currentFolder.tableIds = this.currentFolder.tableIds.filter((id) => id !== tableId); - } else { - // Add to folder - this.currentFolder.tableIds.push(tableId); - // Remove empty flag when adding tables - if (this.currentFolder.isEmpty) { - this.currentFolder.isEmpty = false; - } - } - - console.log('toggleTableInFolder'); - this.saveFolders(); - } - - removeTableFromFolder(folder: Folder, table: TableProperties, event: Event) { - event.stopPropagation(); - folder.tableIds = folder.tableIds.filter((id) => id !== table.table); - console.log('removeTableFromFolder'); - this.saveFolders(); - } - - trackByFolderId(_index: number, folder: Folder): string { - return folder.id; - } - - hasCustomFolders(): boolean { - // Check if there are folders other than "All tables" - return this.folders.some((folder) => folder.id !== 'all-tables-kitten'); - } - - private preserveFolderStates() { - // Save expanded states of all folders - this.folders.forEach((folder) => { - this.preservedFolderStates[folder.id] = folder.expanded; - }); - - // If no custom folders exist, ensure "All tables" is always expanded - if (!this.hasCustomFolders()) { - const allTablesFolder = this.folders.find((folder) => folder.id === 'all-tables-kitten'); - if (allTablesFolder) { - this.preservedFolderStates[allTablesFolder.id] = true; - this.preservedActiveFolder = allTablesFolder.id; - } - } else { - // Find and save the currently active folder (the one that contains selected table) - const activeFolder = this.findActiveFolder(); - this.preservedActiveFolder = activeFolder ? activeFolder.id : null; - } - } - - private restoreFolderStates() { - // If no custom folders exist, always expand "All tables" - if (!this.hasCustomFolders()) { - const allTablesFolder = this.folders.find((folder) => folder.id === 'all-tables-kitten'); - if (allTablesFolder) { - allTablesFolder.expanded = true; - this.currentCollapsedFolder = allTablesFolder; - this.showCollapsedTableList = true; - return; - } - } - - // Restore expanded states of all folders - this.folders.forEach((folder) => { - if (Object.hasOwn(this.preservedFolderStates, folder.id)) { - folder.expanded = this.preservedFolderStates[folder.id]; - } - }); - - // In collapsed view, show the table list if any folder was expanded - const hasExpandedFolders = Object.values(this.preservedFolderStates).some((expanded) => expanded); - if (hasExpandedFolders) { - // If there was an active folder, use it; otherwise use the first expanded folder - let targetFolder = null; - - if (this.preservedActiveFolder) { - targetFolder = this.folders.find((f) => f.id === this.preservedActiveFolder); - } - - if (!targetFolder) { - // Find the first expanded folder - targetFolder = this.folders.find((f) => this.preservedFolderStates[f.id]); - } - - if (targetFolder) { - this.currentCollapsedFolder = targetFolder; - this.showCollapsedTableList = true; - } - } - } - - private findActiveFolder(): Folder | null { - if (!this.selectedTable) return null; - - return this.folders.find((folder) => folder.tableIds.includes(this.selectedTable)) || null; - } - - private generateFolderId(): string { - return Date.now().toString(); - } - - private getDropIndex(event: DragEvent, folder: Folder): number { - const folderEl = event.currentTarget as HTMLElement; - const tableItems = folderEl.querySelectorAll('.selected-table-item'); - const y = event.clientY; - - for (let i = 0; i < tableItems.length; i++) { - const rect = tableItems[i].getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - if (y < midY) { - return i; - } - } - return folder.tableIds.length; - } - - private saveFolders() { - try { - this.tableCategories = this.folders.map((folder) => ({ - category_id: folder.id, - category_name: folder.name, - category_color: folder.iconColor, - tables: folder.tableIds, - })); - this._tablesService.updateTablesFolders(this.connectionID, this.tableCategories).subscribe({ - next: () => { - console.log('Connection settings updated with folders.'); - }, - error: (error) => { - console.error('Error updating connection settings with folders:', error); - }, - }); - } catch (e) { - console.error('Error saving folders:', e); - } - } - - // Table reordering within folders - isReorderingEnabled(folder: Folder): boolean { - return ( - this.canEditConnection() === true && - !this.collapsed && - (!this.substringToSearch || this.substringToSearch.trim() === '') - ); - } - - onTableReorder(event: CdkDragDrop, folder: Folder) { - if (event.previousIndex === event.currentIndex) { - return; - } - moveItemInArray(folder.tableIds, event.previousIndex, event.currentIndex); - this.saveFolders(); - } - - // Drag and drop methods - onTableDragStart(event: DragEvent, table: TableProperties) { - this.draggedTable = table; - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', table.table); - } - } - - onTableDragEnd(_event: DragEvent) { - this.draggedTable = null; - this.dragOverFolder = null; - } - - onFolderDragOver(event: DragEvent, folderId: string) { - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - this.dragOverFolder = folderId; - } - - onFolderDragLeave(event: DragEvent, _folderId: string) { - // Only clear if we're actually leaving the collection (not moving to a child element) - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); - const x = event.clientX; - const y = event.clientY; - - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { - this.dragOverFolder = null; - } - } - - onFolderDrop(event: DragEvent, folder: Folder) { - event.preventDefault(); - - if (this.draggedTable) { - // Check if table is already in this specific folder - if (folder.tableIds.includes(this.draggedTable.table)) { - // Table already exists in this folder, do nothing (no notification) - return; - } else { - // Determine insertion index based on drop position - const insertIndex = this.getDropIndex(event, folder); - folder.tableIds.splice(insertIndex, 0, this.draggedTable.table); - // Remove empty flag when adding tables via drag and drop - if (folder.isEmpty) { - folder.isEmpty = false; - } - this.saveFolders(); - } - } - - this.draggedTable = null; - this.dragOverFolder = null; - } -} diff --git a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css b/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css deleted file mode 100644 index 0e8ea7d7b..000000000 --- a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.css +++ /dev/null @@ -1,99 +0,0 @@ -.chart-mini-preview { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -.chart-svg { - width: 100%; - height: 100%; - max-width: 120px; - max-height: 80px; -} - -/* Bar Chart */ -.bar { - fill: rgba(99, 102, 241, 0.7); -} - -.bar-1 { - fill: rgba(99, 102, 241, 0.8); -} -.bar-2 { - fill: rgba(168, 85, 247, 0.8); -} -.bar-3 { - fill: rgba(236, 72, 153, 0.8); -} -.bar-4 { - fill: rgba(59, 130, 246, 0.8); -} -.bar-5 { - fill: rgba(34, 197, 94, 0.8); -} - -/* Line Chart */ -.line-path { - stroke: rgba(99, 102, 241, 1); - stroke-width: 2.5; - stroke-linecap: round; - stroke-linejoin: round; -} - -.line-area { - fill: rgba(99, 102, 241, 0.15); -} - -.line-dot { - fill: rgba(99, 102, 241, 1); -} - -.gradient-start { - stop-color: rgba(99, 102, 241, 0.3); -} - -.gradient-end { - stop-color: rgba(99, 102, 241, 0); -} - -/* Pie Chart */ -.pie-slice { - stroke: var(--mat-sidenav-content-background-color); - stroke-width: 1; -} - -.slice-1 { - fill: rgba(99, 102, 241, 0.85); -} -.slice-2 { - fill: rgba(168, 85, 247, 0.85); -} -.slice-3 { - fill: rgba(236, 72, 153, 0.85); -} -.slice-4 { - fill: rgba(251, 146, 60, 0.85); -} -.slice-5 { - fill: rgba(34, 197, 94, 0.85); -} - -/* Doughnut Chart */ -.doughnut-slice { - stroke: var(--mat-sidenav-content-background-color); - stroke-width: 1; -} - -/* Polar Area Chart */ -.polar-slice { - stroke: var(--mat-sidenav-content-background-color); - stroke-width: 1; -} - -@media (prefers-color-scheme: dark) { - .line-area { - fill: rgba(99, 102, 241, 0.25); - } -} diff --git a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.html b/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.html deleted file mode 100644 index 13c0315bc..000000000 --- a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.html +++ /dev/null @@ -1,54 +0,0 @@ -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.ts b/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.ts deleted file mode 100644 index 0acaf1852..000000000 --- a/frontend/src/app/components/dashboards/chart-mini-preview/chart-mini-preview.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { ChartType } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-chart-mini-preview', - templateUrl: './chart-mini-preview.component.html', - styleUrls: ['./chart-mini-preview.component.css'], - imports: [CommonModule], -}) -export class ChartMiniPreviewComponent { - @Input() chartType: ChartType | null = 'bar'; - - get displayType(): ChartType { - return this.chartType || 'bar'; - } -} diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css deleted file mode 100644 index bf205d440..000000000 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css +++ /dev/null @@ -1,28 +0,0 @@ -.warning-container { - display: flex; - gap: 16px; - align-items: flex-start; -} - -.warning-icon { - color: var(--color-warnPalette-300); - font-size: 48px; - width: 48px; - height: 48px; - flex-shrink: 0; -} - -.warning-content p { - margin: 0 0 8px 0; -} - -.warning-details { - color: rgba(0, 0, 0, 0.6); - font-size: 14px; -} - -@media (prefers-color-scheme: dark) { - .warning-details { - color: rgba(255, 255, 255, 0.6); - } -} diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html deleted file mode 100644 index d460175d8..000000000 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html +++ /dev/null @@ -1,23 +0,0 @@ -

Delete dashboard

- - -
- warning -
-

Are you sure you want to delete the dashboard {{data.dashboard.name}}?

-

- This action cannot be undone. The dashboard and all its widgets will be permanently removed. -

-
-
-
- - - - - diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts deleted file mode 100644 index c0ddfc097..000000000 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { DashboardDeleteDialogComponent } from './dashboard-delete-dialog.component'; - -describe('DashboardDeleteDialogComponent', () => { - let component: DashboardDeleteDialogComponent; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - - beforeEach(async () => { - mockDashboardsService = { - deleteDashboard: vi.fn(), - } as Partial; - - await TestBed.configureTestingModule({ - imports: [DashboardDeleteDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionId: 'test-conn', - dashboard: { id: 'test-id', name: 'Test Dashboard' }, - }, - }, - { provide: MatDialogRef, useValue: { close: vi.fn() } }, - { provide: DashboardsService, useValue: mockDashboardsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DashboardDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.stories.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.stories.ts deleted file mode 100644 index c15e1f2c1..000000000 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.stories.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { DashboardDeleteDialogComponent } from './dashboard-delete-dialog.component'; - -const mockDashboardsService: Partial = { - deleteDashboard: () => Promise.resolve(null), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/DashboardDelete', - component: DashboardDeleteDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - dashboard: { - id: 'dash-1', - name: 'Sales Dashboard', - description: 'Monthly sales metrics', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }, - connectionId: 'conn-1', - }, - }, - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts deleted file mode 100644 index 39447c592..000000000 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Dashboard } from 'src/app/models/dashboard'; -import { DashboardsService } from 'src/app/services/dashboards.service'; - -@Component({ - selector: 'app-dashboard-delete-dialog', - templateUrl: './dashboard-delete-dialog.component.html', - styleUrls: ['./dashboard-delete-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], -}) -export class DashboardDeleteDialogComponent { - protected submitting = signal(false); - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { dashboard: Dashboard; connectionId: string }, - private dialogRef: MatDialogRef, - private _dashboards: DashboardsService, - private angulartics2: Angulartics2, - ) {} - - async onDelete(): Promise { - this.submitting.set(true); - const result = await this._dashboards.deleteDashboard(this.data.connectionId, this.data.dashboard.id); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard deleted successfully', - }); - posthog.capture('Dashboards: dashboard deleted successfully'); - this.dialogRef.close(true); - } - this.submitting.set(false); - } -} diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css deleted file mode 100644 index 87944b9f3..000000000 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.dashboard-form { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 400px; - padding-top: 8px; -} - -@media (width <= 600px) { - .dashboard-form { - min-width: auto; - } -} - -.full-width { - width: 100%; -} diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html deleted file mode 100644 index b6821391f..000000000 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html +++ /dev/null @@ -1,42 +0,0 @@ -
-

{{ isEdit ? 'Edit dashboard' : 'Create dashboard' }}

- - -
- - Name - - @if (form.get('name')?.hasError('required')) { - Name is required - } - @if (form.get('name')?.hasError('maxlength')) { - Name must be less than 255 characters - } - - - - Description - - @if (form.get('description')?.hasError('maxlength')) { - Description must be less than 1000 characters - } - -
-
- - - - - -
diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts deleted file mode 100644 index d4354b859..000000000 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { DashboardEditDialogComponent } from './dashboard-edit-dialog.component'; - -describe('DashboardEditDialogComponent', () => { - let component: DashboardEditDialogComponent; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - - beforeEach(async () => { - mockDashboardsService = { - createDashboard: vi.fn(), - updateDashboard: vi.fn(), - } as Partial; - - await TestBed.configureTestingModule({ - imports: [DashboardEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: MAT_DIALOG_DATA, useValue: { connectionId: 'test-conn', dashboard: null } }, - { provide: MatDialogRef, useValue: { close: vi.fn() } }, - { provide: DashboardsService, useValue: mockDashboardsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DashboardEditDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.stories.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.stories.ts deleted file mode 100644 index 16accb230..000000000 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.stories.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { DashboardEditDialogComponent } from './dashboard-edit-dialog.component'; - -const mockDashboardsService: Partial = { - createDashboard: () => Promise.resolve(null), - updateDashboard: () => Promise.resolve(null), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/DashboardEdit', - component: DashboardEditDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionId: 'conn-1', - dashboard: null, - }, - }, - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const CreateNew: Story = {}; - -export const EditExisting: Story = { - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionId: 'conn-1', - dashboard: { - id: 'dash-1', - name: 'Sales Dashboard', - description: 'Monthly sales metrics and KPIs', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }, - }, - }, - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts deleted file mode 100644 index 96e7607af..000000000 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit, signal } from '@angular/core'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Dashboard } from 'src/app/models/dashboard'; -import { DashboardsService } from 'src/app/services/dashboards.service'; - -@Component({ - selector: 'app-dashboard-edit-dialog', - templateUrl: './dashboard-edit-dialog.component.html', - styleUrls: ['./dashboard-edit-dialog.component.css'], - imports: [ - CommonModule, - ReactiveFormsModule, - MatDialogModule, - MatButtonModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - ], -}) -export class DashboardEditDialogComponent implements OnInit { - protected submitting = signal(false); - protected form!: FormGroup; - protected isEdit: boolean; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { connectionId: string; dashboard: Dashboard | null }, - private dialogRef: MatDialogRef, - private _dashboards: DashboardsService, - private fb: FormBuilder, - private angulartics2: Angulartics2, - ) { - this.isEdit = !!data.dashboard; - } - - ngOnInit(): void { - this.form = this.fb.group({ - name: [this.data.dashboard?.name || '', [Validators.required, Validators.maxLength(255)]], - description: [this.data.dashboard?.description || '', [Validators.maxLength(1000)]], - }); - } - - async onSubmit(): Promise { - if (this.form.invalid) return; - - this.submitting.set(true); - const payload = this.form.value; - - if (this.isEdit) { - const result = await this._dashboards.updateDashboard(this.data.connectionId, this.data.dashboard!.id, payload); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard updated successfully', - }); - posthog.capture('Dashboards: dashboard updated successfully'); - this.dialogRef.close(true); - } - } else { - const result = await this._dashboards.createDashboard(this.data.connectionId, payload); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard created successfully', - }); - posthog.capture('Dashboards: dashboard created successfully'); - this.dialogRef.close(true); - } - } - this.submitting.set(false); - } -} diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css deleted file mode 100644 index db3020ee9..000000000 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css +++ /dev/null @@ -1,281 +0,0 @@ -/* Scoped to app-dashboard-view to prevent style leakage with ViewEncapsulation.None */ - -app-dashboard-view .dashboard-view-page { - padding: 24px; - height: calc(100vh - 64px); - display: flex; - flex-direction: column; - background-color: var(--mat-sidenav-content-background-color); -} - -app-dashboard-view .dashboard-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 24px; - flex-wrap: wrap; - gap: 16px; -} - -app-dashboard-view .header-left { - display: flex; - align-items: center; - gap: 8px; -} - -app-dashboard-view .header-info { - display: flex; - flex-direction: column; -} - -app-dashboard-view .header-info h1 { - margin: 0; - line-height: 1.2; -} - -app-dashboard-view .dashboard-description { - margin: 4px 0 0 0; - color: rgba(0, 0, 0, 0.64); - font-size: 14px; -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .dashboard-description { - color: rgba(255, 255, 255, 0.7); - } -} - -app-dashboard-view .header-actions { - display: flex; - align-items: center; - gap: 16px; -} - -app-dashboard-view .loading-container { - display: flex; - justify-content: center; - align-items: center; - flex: 1; -} - -app-dashboard-view .no-panels { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - text-align: center; - background-color: rgba(0, 0, 0, 0.02); - border-radius: 8px; - padding: 64px 24px; -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .no-panels { - background-color: rgba(255, 255, 255, 0.05); - } -} - -app-dashboard-view .no-panels-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: rgba(0, 0, 0, 0.26); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .no-panels-icon { - color: rgba(255, 255, 255, 0.3); - } -} - -app-dashboard-view .no-panels h3 { - margin: 0 0 8px 0; - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .no-panels h3 { - color: rgba(255, 255, 255, 0.87); - } -} - -app-dashboard-view .no-panels p { - margin: 0 0 16px 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .no-panels p { - color: rgba(255, 255, 255, 0.54); - } -} - -app-dashboard-view .dashboard-grid { - flex: 1; - background-color: rgba(0, 0, 0, 0.02); - border-radius: 8px; -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .dashboard-grid { - background-color: var(--surface-dark-color); - } -} - -app-dashboard-view .panel-item { - background: var(--mat-sidenav-content-background-color); - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - display: flex; - flex-direction: column; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .panel-item { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - } -} - -app-dashboard-view .panel-item.edit-mode { - border: 2px dashed var(--color-accentedPalette-500); -} - -app-dashboard-view .panel-item.edit-mode .panel-header { - cursor: move; -} - -/* Gridster resize handles - gridster controls visibility via resizable.enabled */ -app-dashboard-view gridster-item .gridster-item-resizable-handler { - position: absolute; - z-index: 100; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-s { - bottom: -4px; - left: 25%; - width: 50%; - height: 8px; - cursor: ns-resize; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-e { - right: -4px; - top: 25%; - width: 8px; - height: 50%; - cursor: ew-resize; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-n { - top: -4px; - left: 25%; - width: 50%; - height: 8px; - cursor: ns-resize; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-w { - left: -4px; - top: 25%; - width: 8px; - height: 50%; - cursor: ew-resize; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-se { - bottom: -4px; - right: -4px; - width: 16px; - height: 16px; - cursor: se-resize; - background: var(--color-accentedPalette-500); - border-radius: 2px 0 6px 0; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-ne { - top: -4px; - right: -4px; - width: 16px; - height: 16px; - cursor: ne-resize; - background: var(--color-accentedPalette-500); - border-radius: 0 6px 0 2px; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-sw { - bottom: -4px; - left: -4px; - width: 16px; - height: 16px; - cursor: sw-resize; - background: var(--color-accentedPalette-500); - border-radius: 0 2px 0 6px; -} - -app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-nw { - top: -4px; - left: -4px; - width: 16px; - height: 16px; - cursor: nw-resize; - background: var(--color-accentedPalette-500); - border-radius: 6px 0 2px 0; -} - -app-dashboard-view .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 8px 8px 16px; - border-bottom: 1px solid var(--mat-sidenav-container-divider-color); - flex-shrink: 0; -} - -app-dashboard-view .panel-title { - font-weight: 500; - font-size: 14px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -app-dashboard-view .panel-menu-button { - flex-shrink: 0; -} - -app-dashboard-view .panel-content { - flex: 1; - padding: 16px; - overflow: auto; - display: flex; - flex-direction: column; -} - -app-dashboard-view .delete-action { - color: var(--color-warnPalette-500); -} - -@media (prefers-color-scheme: dark) { - app-dashboard-view .delete-action { - color: var(--color-warnDarkPalette-500); - } -} - -@media (width <= 600px) { - app-dashboard-view .dashboard-view-page { - padding: 16px; - } - - app-dashboard-view .dashboard-header { - flex-direction: column; - align-items: flex-start; - } - - app-dashboard-view .header-actions { - width: 100%; - justify-content: space-between; - } -} diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html deleted file mode 100644 index 5f1b52ad9..000000000 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html +++ /dev/null @@ -1,87 +0,0 @@ - - -
-
-
- -
-

{{ dashboard()?.name || 'Loading...' }}

- @if (dashboard()?.description) { -

{{ dashboard()?.description }}

- } -
-
- @if (gridsterItems().length > 0) { -
- - Edit mode - - -
- } -
- - @if (loading()) { -
- -
- } - - @if (!loading() && gridsterItems().length === 0) { -
- bar_chart -

No panels yet

-

Add panels to visualize your data from saved queries.

- -
- } - - @if (!loading() && gridsterItems().length > 0) { - - @for (item of gridsterItems(); track item.widget.id) { - -
- {{ panelRef.savedQuery()?.name || 'Loading...' }} - - - - tune - Configure panel - - - - -
-
- - -
-
- } -
- } -
diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts deleted file mode 100644 index fba785505..000000000 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { Dashboard } from 'src/app/models/dashboard'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { DashboardViewComponent } from './dashboard-view.component'; - -describe('DashboardViewComponent', () => { - let component: DashboardViewComponent; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - let mockConnectionsService: Partial; - let dashboardSignal: WritableSignal; - let dashboardLoadingSignal: WritableSignal; - - beforeEach(async () => { - dashboardSignal = signal(null); - dashboardLoadingSignal = signal(false); - - mockDashboardsService = { - dashboard: dashboardSignal.asReadonly(), - dashboardLoading: dashboardLoadingSignal.asReadonly(), - setActiveConnection: vi.fn(), - setActiveDashboard: vi.fn(), - refreshDashboard: vi.fn(), - }; - - mockConnectionsService = { - getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), - }; - - await TestBed.configureTestingModule({ - imports: [DashboardViewComponent, BrowserAnimationsModule, RouterTestingModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DashboardViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts deleted file mode 100644 index 96d4b2dd9..000000000 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, DestroyRef, effect, inject, OnInit, signal, ViewEncapsulation } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { - CompactType, - DisplayGrid, - GridsterComponent, - GridsterConfig, - GridsterItem, - GridsterItemComponent, - GridType, -} from 'angular-gridster2'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { PanelDeleteDialogComponent } from '../panel-delete-dialog/panel-delete-dialog.component'; -import { PanelEditDialogComponent } from '../panel-edit-dialog/panel-edit-dialog.component'; -import { DashboardPanelComponent } from '../panel-renderers/dashboard-panel/dashboard-panel.component'; - -interface GridsterPanelItem extends GridsterItem { - widget: DashboardWidget; -} - -@Component({ - selector: 'app-dashboard-view', - templateUrl: './dashboard-view.component.html', - styleUrls: ['./dashboard-view.component.css'], - encapsulation: ViewEncapsulation.None, - imports: [ - CommonModule, - RouterModule, - MatButtonModule, - MatIconModule, - MatMenuModule, - MatTooltipModule, - MatSlideToggleModule, - MatProgressSpinnerModule, - GridsterComponent, - GridsterItemComponent, - AlertComponent, - DashboardPanelComponent, - ], -}) -export class DashboardViewComponent implements OnInit { - protected connectionId = signal(''); - protected dashboardId = signal(''); - protected editMode = signal(false); - - private _dashboards = inject(DashboardsService); - private _connections = inject(ConnectionsService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private dialog = inject(MatDialog); - private angulartics2 = inject(Angulartics2); - private title = inject(Title); - private destroyRef = inject(DestroyRef); - - // Use service signals - protected dashboard = computed(() => this._dashboards.dashboard()); - protected loading = computed(() => this._dashboards.dashboardLoading()); - - // Writable signal for gridster items (gridster needs mutable items) - protected gridsterItems = signal([]); - - // Connection title signal (bridging from legacy Observable-based service) - private connectionTitle = signal(''); - - protected gridsterOptions: GridsterConfig = { - gridType: GridType.Fit, - compactType: CompactType.None, - displayGrid: DisplayGrid.OnDragAndResize, - pushItems: true, - draggable: { - enabled: false, - ignoreContentClass: 'panel-content', - ignoreContent: true, - dragHandleClass: 'panel-header', - }, - resizable: { - enabled: false, - handles: { - s: true, - e: true, - n: true, - w: true, - se: true, - ne: true, - sw: true, - nw: true, - }, - }, - minCols: 12, - maxCols: 12, - minRows: 8, - maxRows: 100, - defaultItemCols: 4, - defaultItemRows: 4, - minItemCols: 2, - minItemRows: 2, - maxItemCols: 12, - maxItemRows: 12, - itemChangeCallback: (item: GridsterItem) => this._onItemChange(item as GridsterPanelItem), - }; - - constructor() { - // Subscribe to connection title (legacy service bridge) - this._connections - .getCurrentConnectionTitle() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((title) => this.connectionTitle.set(title)); - - // Sync gridster items when dashboard changes - effect(() => { - const dashboard = this.dashboard(); - if (dashboard?.widgets) { - this.gridsterItems.set( - dashboard.widgets.map((widget) => ({ - x: widget.position_x, - y: widget.position_y, - cols: widget.width, - rows: widget.height, - widget: widget, - })), - ); - } else { - this.gridsterItems.set([]); - } - }); - - // Connection title effect - effect(() => { - const dashboard = this.dashboard(); - const connectionTitle = this.connectionTitle(); - if (dashboard) { - this.title.setTitle(`${dashboard.name} | ${connectionTitle || 'Rocketadmin'}`); - } - }); - - // Edit mode effect - effect(() => { - const editMode = this.editMode(); - if (this.gridsterOptions.draggable) { - this.gridsterOptions.draggable.enabled = editMode; - } - if (this.gridsterOptions.resizable) { - this.gridsterOptions.resizable.enabled = editMode; - } - this.gridsterOptions.displayGrid = editMode ? DisplayGrid.Always : DisplayGrid.OnDragAndResize; - if (this.gridsterOptions.api?.optionsChanged) { - this.gridsterOptions.api.optionsChanged(); - } - }); - } - - ngOnInit(): void { - const connId = this.route.snapshot.paramMap.get('connection-id') || ''; - const dashId = this.route.snapshot.paramMap.get('dashboard-id') || ''; - this.connectionId.set(connId); - this.dashboardId.set(dashId); - this._dashboards.setActiveConnection(connId); - this._dashboards.setActiveDashboard(dashId); - } - - toggleEditMode(): void { - this.editMode.update((v) => !v); - this.angulartics2.eventTrack.next({ - action: `Dashboards: edit mode ${this.editMode() ? 'enabled' : 'disabled'}`, - }); - posthog.capture(`Dashboards: edit mode ${this.editMode() ? 'enabled' : 'disabled'}`); - } - - async openAddPanelDialog(): Promise { - const dialogRef = this.dialog.open(PanelEditDialogComponent, { - width: '600px', - data: { - connectionId: this.connectionId(), - dashboardId: this.dashboardId(), - widget: null, - }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: add panel dialog opened', - }); - posthog.capture('Dashboards: add panel dialog opened'); - - const result = await dialogRef.afterClosed().toPromise(); - if (result) { - this._dashboards.refreshDashboard(); - } - } - - async openEditPanelDialog(widget: DashboardWidget): Promise { - const dialogRef = this.dialog.open(PanelEditDialogComponent, { - width: '600px', - data: { - connectionId: this.connectionId(), - dashboardId: this.dashboardId(), - widget: widget, - }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: edit panel dialog opened', - }); - posthog.capture('Dashboards: edit panel dialog opened'); - - const result = await dialogRef.afterClosed().toPromise(); - if (result) { - this._dashboards.refreshDashboard(); - } - } - - async openDeletePanelDialog(widget: DashboardWidget): Promise { - const dialogRef = this.dialog.open(PanelDeleteDialogComponent, { - width: '400px', - data: { - connectionId: this.connectionId(), - dashboardId: this.dashboardId(), - widget: widget, - }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: delete panel dialog opened', - }); - posthog.capture('Dashboards: delete panel dialog opened'); - - const result = await dialogRef.afterClosed().toPromise(); - if (result) { - this._dashboards.refreshDashboard(); - } - } - - navigateBack(): void { - this.router.navigate(['/dashboards', this.connectionId()]); - } - - private async _onItemChange(item: GridsterPanelItem): Promise { - const widget = item.widget; - if ( - widget.position_x !== item.x || - widget.position_y !== item.y || - widget.width !== item.cols || - widget.height !== item.rows - ) { - // Update local widget state to prevent duplicate saves - widget.position_x = item.x ?? widget.position_x; - widget.position_y = item.y ?? widget.position_y; - widget.width = item.cols ?? widget.width; - widget.height = item.rows ?? widget.height; - - // Save to backend - await this._dashboards.updateWidgetPosition(this.connectionId(), this.dashboardId(), widget.id, { - position_x: widget.position_x, - position_y: widget.position_y, - width: widget.width, - height: widget.height, - }); - } - } -} diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css deleted file mode 100644 index 818a7e504..000000000 --- a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css +++ /dev/null @@ -1,354 +0,0 @@ -:host { - display: block; - height: calc(100vh - 44px); - margin-bottom: -60px; - overflow: hidden; - position: relative; - z-index: 1; -} - -.dashboards-layout { - display: flex; - height: 100%; -} - -.dashboards-main { - flex: 1; - background-color: var(--mat-sidenav-content-background-color); - overflow-y: auto; -} - -.dashboards-page { - margin: 3em auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1400px; -} - -@media (width <= 600px) { - .dashboards-page { - padding: 0 16px; - margin: 1.5em auto; - } -} - -.dashboards-header { - margin-bottom: 32px; -} - -.dashboards-description { - color: rgba(0, 0, 0, 0.64); - margin-top: 8px; -} - -@media (prefers-color-scheme: dark) { - .dashboards-description { - color: rgba(255, 255, 255, 0.7); - } -} - -.dashboards-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; -} - -@media (width <= 600px) { - .dashboards-toolbar { - flex-direction: column; - align-items: stretch; - } -} - -.search-field { - flex: 1; - max-width: 400px; -} - -@media (width <= 600px) { - .search-field { - max-width: 100%; - } -} - -/* Cards Grid */ -.dashboards-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 24px; -} - -@media (width <= 600px) { - .dashboards-grid { - grid-template-columns: 1fr; - gap: 16px; - } -} - -/* Dashboard Card */ -.dashboard-card { - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - transition: - box-shadow 0.2s ease, - transform 0.2s ease, - border-color 0.2s ease; -} - -.dashboard-card:hover { - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); - transform: translateY(-2px); - border-color: rgba(0, 0, 0, 0.18); -} - -@media (prefers-color-scheme: dark) { - .dashboard-card { - background: color-mix(in srgb, var(--color-accentedPalette-500) 5%, transparent); - border-color: #1e293b; - } - - .dashboard-card:hover { - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - border-color: #334155; - } -} - -/* Preview Area */ -.dashboard-preview { - display: block; - height: 160px; - background-color: rgba(0, 0, 0, 0.03); - border-bottom: 1px solid var(--mat-sidenav-container-divider-color); - text-decoration: none; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .dashboard-preview { - background-color: transparent; - border-bottom-color: rgba(255, 255, 255, 0.06); - } -} - -.preview-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - background-color: rgba(0, 0, 0, 0.02); -} - -.preview-placeholder-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: rgba(0, 0, 0, 0.18); -} - -.preview-placeholder-hint { - font-size: 12px; - color: rgba(0, 0, 0, 0.3); - margin-top: 4px; -} - -@media (prefers-color-scheme: dark) { - .preview-placeholder { - background-color: transparent; - } - - .preview-placeholder-icon { - color: rgba(255, 255, 255, 0.2); - } - - .preview-placeholder-hint { - color: rgba(255, 255, 255, 0.3); - } -} - -.preview-chart { - display: flex; - align-items: center; - justify-content: center; - height: 100%; -} - -/* Card Content */ -.dashboard-card-content { - padding: 16px; - flex: 1; - display: flex; - flex-direction: column; -} - -.dashboard-card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - margin-bottom: 8px; -} - -.dashboard-name { - font-size: 16px; - font-weight: 500; - color: inherit; - text-decoration: none; - line-height: 1.4; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.dashboard-name:hover { - color: var(--color-accentedPalette-500); -} - -.dashboard-menu-button { - flex-shrink: 0; - margin: -8px -8px 0 0; -} - -.dashboard-description-text { - font-size: 14px; - color: rgba(0, 0, 0, 0.6); - margin: 0 0 12px 0; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -@media (prefers-color-scheme: dark) { - .dashboard-description-text { - color: rgba(255, 255, 255, 0.6); - } -} - -.dashboard-meta { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: auto; - font-size: 12px; - color: rgba(0, 0, 0, 0.5); -} - -@media (prefers-color-scheme: dark) { - .dashboard-meta { - color: rgba(255, 255, 255, 0.5); - } -} - -.dashboard-widgets-count { - display: flex; - align-items: center; - gap: 4px; -} - -.meta-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -/* Empty State */ -.no-dashboards { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 64px 24px; - text-align: center; -} - -.no-dashboards-illustration { - width: 120px; - height: 100px; - margin-bottom: 16px; -} - -/* Illustration — light theme */ -.illust-card-back { - fill: #e2e8f0; - stroke: #cbd5e1; - stroke-width: 1.5; -} -.illust-card-mid { - fill: #eef2f7; - stroke: #cbd5e1; - stroke-width: 1.5; -} -.illust-card-front { - fill: #f8fafc; - stroke: #94a3b8; - stroke-width: 1.5; -} -.illust-detail { - fill: #cbd5e1; -} -.illust-detail-accent { - fill: #94a3b8; -} - -/* Illustration — dark theme */ -@media (prefers-color-scheme: dark) { - .illust-card-back { - fill: #0f1219; - stroke: #1e293b; - } - .illust-card-mid { - fill: #141820; - stroke: #1e293b; - } - .illust-card-front { - fill: #1a1f2e; - stroke: #334155; - } - .illust-detail { - fill: #1e293b; - } - .illust-detail-accent { - fill: #334155; - } -} - -.no-dashboards h3 { - margin: 0 0 8px 0; - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .no-dashboards h3 { - color: rgba(255, 255, 255, 0.87); - } -} - -.no-dashboards p { - margin: 0 0 16px 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-dashboards p { - color: rgba(255, 255, 255, 0.54); - } -} - -.delete-action { - color: var(--color-warnPalette-500); -} - -@media (prefers-color-scheme: dark) { - .delete-action { - color: var(--color-warnDarkPalette-500); - } -} diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html deleted file mode 100644 index 6e367003c..000000000 --- a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html +++ /dev/null @@ -1,150 +0,0 @@ -
- - - -
- - -
-
-

Dashboards

-

- Create and manage custom dashboards with charts, tables, and metrics. -

-
- -
- - Search dashboards - - search - - - -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

No dashboards yet

-

No dashboards match your search criteria.

-

Dashboards help you visualize your data with charts, tables, and metrics — all in one place.

- -
- -
- - - - -
- dashboard - Connect your first panel -
-
- -
-
- - - -
- - {{dashboard.name}} - - - - - visibility - View - - - - - -
-

- {{dashboard.description}} -

-
- - widgets - {{dashboard.widgets?.length || 0}} panels - - - {{dashboard.updated_at | date:'mediumDate'}} - -
-
-
-
-
-
-
diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts deleted file mode 100644 index 596ce1129..000000000 --- a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { Dashboard } from 'src/app/models/dashboard'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService, DashboardUpdateEvent } from 'src/app/services/dashboards.service'; -import { DashboardsListComponent } from './dashboards-list.component'; - -describe('DashboardsListComponent', () => { - let component: DashboardsListComponent; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - let mockConnectionsService: Partial; - let dashboardsUpdatedSignal: WritableSignal; - let dashboardsSignal: WritableSignal; - let dashboardsLoadingSignal: WritableSignal; - - beforeEach(async () => { - dashboardsUpdatedSignal = signal(''); - dashboardsSignal = signal([]); - dashboardsLoadingSignal = signal(false); - - mockDashboardsService = { - dashboards: dashboardsSignal.asReadonly(), - dashboardsLoading: dashboardsLoadingSignal.asReadonly(), - dashboardsUpdated: dashboardsUpdatedSignal.asReadonly(), - setActiveConnection: vi.fn(), - setActiveDashboard: vi.fn(), - refreshDashboards: vi.fn(), - }; - - mockConnectionsService = { - getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), - }; - - await TestBed.configureTestingModule({ - imports: [DashboardsListComponent, BrowserAnimationsModule, RouterTestingModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DashboardsListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts deleted file mode 100644 index 28550d2ae..000000000 --- a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatDialog } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, RouterModule } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Dashboard } from 'src/app/models/dashboard'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { PlaceholderDashboardsComponent } from '../../skeletons/placeholder-dashboards/placeholder-dashboards.component'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { ChartMiniPreviewComponent } from '../chart-mini-preview/chart-mini-preview.component'; -import { DashboardDeleteDialogComponent } from '../dashboard-delete-dialog/dashboard-delete-dialog.component'; -import { DashboardEditDialogComponent } from '../dashboard-edit-dialog/dashboard-edit-dialog.component'; -import { DashboardsSidebarComponent } from '../dashboards-sidebar/dashboards-sidebar.component'; - -@Component({ - selector: 'app-dashboards-list', - templateUrl: './dashboards-list.component.html', - styleUrls: ['./dashboards-list.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatButtonModule, - MatCardModule, - MatIconModule, - MatMenuModule, - MatInputModule, - MatFormFieldModule, - MatTooltipModule, - MatDividerModule, - PlaceholderDashboardsComponent, - AlertComponent, - DashboardsSidebarComponent, - ChartMiniPreviewComponent, - ], -}) -export class DashboardsListComponent implements OnInit { - protected searchQuery = signal(''); - protected connectionId = signal(''); - - private _dashboards = inject(DashboardsService); - private _connections = inject(ConnectionsService); - private route = inject(ActivatedRoute); - private dialog = inject(MatDialog); - private angulartics2 = inject(Angulartics2); - private title = inject(Title); - private destroyRef = inject(DestroyRef); - - // Use service signals for dashboards and loading - protected dashboards = computed(() => this._dashboards.dashboards()); - protected loading = computed(() => this._dashboards.dashboardsLoading()); - - protected filteredDashboards = computed(() => { - const dashboards = this.dashboards(); - const search = this.searchQuery(); - if (!search) return dashboards; - const query = search.toLowerCase(); - return dashboards.filter( - (d) => d.name.toLowerCase().includes(query) || (d.description && d.description.toLowerCase().includes(query)), - ); - }); - - // Connection title signal (bridging from legacy Observable-based service) - private connectionTitle = signal(''); - - constructor() { - // Subscribe to connection title (legacy service bridge) - this._connections - .getCurrentConnectionTitle() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((title) => this.connectionTitle.set(title)); - - // Connection title effect - effect(() => { - const title = this.connectionTitle(); - this.title.setTitle(`Dashboards | ${title || 'Rocketadmin'}`); - }); - - // Dashboards update effect - effect(() => { - const action = this._dashboards.dashboardsUpdated(); - if (action) { - this._dashboards.refreshDashboards(); - this._dashboards.clearDashboardsUpdated(); - } - }); - } - - ngOnInit(): void { - const connId = this.route.snapshot.paramMap.get('connection-id') || ''; - this.connectionId.set(connId); - this._dashboards.setActiveDashboard(null); - this._dashboards.setActiveConnection(connId); - } - - trackViewDashboardOpened(): void { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: view dashboard opened', - }); - posthog.capture('Dashboards: view dashboard opened'); - } - - openCreateDialog(): void { - this.dialog.open(DashboardEditDialogComponent, { - width: '500px', - data: { connectionId: this.connectionId(), dashboard: null }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: create dashboard dialog opened', - }); - posthog.capture('Dashboards: create dashboard dialog opened'); - } - - openEditDialog(dashboard: Dashboard): void { - this.dialog.open(DashboardEditDialogComponent, { - width: '500px', - data: { connectionId: this.connectionId(), dashboard }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: edit dashboard dialog opened', - }); - posthog.capture('Dashboards: edit dashboard dialog opened'); - } - - openDeleteDialog(dashboard: Dashboard): void { - this.dialog.open(DashboardDeleteDialogComponent, { - width: '400px', - data: { dashboard, connectionId: this.connectionId() }, - }); - this.angulartics2.eventTrack.next({ - action: 'Dashboards: delete dashboard dialog opened', - }); - posthog.capture('Dashboards: delete dashboard dialog opened'); - } -} diff --git a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css b/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css deleted file mode 100644 index 437b413ee..000000000 --- a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.css +++ /dev/null @@ -1,86 +0,0 @@ -.dashboards-sidebar { - width: 220px; - min-width: 220px; - height: 100%; - border-right: 1px solid var(--mat-sidenav-container-divider-color); - display: flex; - flex-direction: column; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .dashboards-sidebar { - background-color: var(--surface-dark-color); - } -} - -.dashboards-sidebar_initialized { - transition: - width 0.2s ease-in-out, - min-width 0.2s ease-in-out; -} - -.dashboards-sidebar_collapsed { - width: 56px; - min-width: 56px; -} - -.sidebar-toggle-button { - align-self: flex-start; - margin-top: 8px; - margin-left: 12px; - margin-bottom: 8px; -} - -.sidebar-toggle-button_collapsed { - justify-content: center; - margin: 12px 8px; -} - -.sidebar-nav { - padding-top: 0; - overflow: hidden; -} - -.sidebar-nav-item { - margin: 4px 8px; - border-radius: 8px; - overflow: hidden; -} - -.sidebar-nav-item ::ng-deep .mdc-list-item__primary-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sidebar-nav-item_collapsed { - margin: 4px; -} - -.sidebar-nav-item_collapsed ::ng-deep .mdc-list-item__content { - padding: 0; - display: flex; - justify-content: center; -} - -.active-link { - background-color: var(--color-accentedPalette-100); -} - -@media (prefers-color-scheme: dark) { - .active-link { - background-color: var(--color-accentedPalette-800); - } -} - -.active-link mat-icon { - color: var(--color-accentedPalette-500); -} - -/* Mobile styles */ -@media (width <= 600px) { - .dashboards-sidebar { - display: none; - } -} diff --git a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.html b/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.html deleted file mode 100644 index 64f1add0d..000000000 --- a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.html +++ /dev/null @@ -1,42 +0,0 @@ -
- - - - - - - - - dashboard - Dashboards - - - widgets - Panels - - -
diff --git a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.ts b/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.ts deleted file mode 100644 index f918151d8..000000000 --- a/frontend/src/app/components/dashboards/dashboards-sidebar/dashboards-sidebar.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, inject, input, OnInit, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { UiSettingsService } from '../../../services/ui-settings.service'; - -@Component({ - selector: 'app-dashboards-sidebar', - templateUrl: './dashboards-sidebar.component.html', - styleUrls: ['./dashboards-sidebar.component.css'], - imports: [CommonModule, RouterModule, MatListModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class DashboardsSidebarComponent implements OnInit, AfterViewInit { - connectionId = input.required(); - activeTab = input<'dashboards' | 'queries'>('dashboards'); - - collapsed = signal(false); - initialized = signal(false); - - private _uiSettings = inject(UiSettingsService); - - ngOnInit(): void { - this._loadCollapsedState(); - } - - ngAfterViewInit(): void { - // Enable transitions after initial render to prevent animation on page load - setTimeout(() => this.initialized.set(true), 0); - } - - toggleCollapsed(): void { - this.collapsed.update((v) => { - const newValue = !v; - this._uiSettings.updateConnectionSetting(this.connectionId(), 'dashboardsSidebarCollapsed', newValue); - return newValue; - }); - } - - private _loadCollapsedState(): void { - this._uiSettings.getUiSettings().subscribe((settings) => { - this.collapsed.set(settings?.connections?.[this.connectionId()]?.dashboardsSidebarCollapsed ?? false); - }); - } -} diff --git a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.css b/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.css deleted file mode 100644 index bf205d440..000000000 --- a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.css +++ /dev/null @@ -1,28 +0,0 @@ -.warning-container { - display: flex; - gap: 16px; - align-items: flex-start; -} - -.warning-icon { - color: var(--color-warnPalette-300); - font-size: 48px; - width: 48px; - height: 48px; - flex-shrink: 0; -} - -.warning-content p { - margin: 0 0 8px 0; -} - -.warning-details { - color: rgba(0, 0, 0, 0.6); - font-size: 14px; -} - -@media (prefers-color-scheme: dark) { - .warning-details { - color: rgba(255, 255, 255, 0.6); - } -} diff --git a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.html b/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.html deleted file mode 100644 index 197350dd1..000000000 --- a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.html +++ /dev/null @@ -1,23 +0,0 @@ -

Delete panel

- - -
- warning -
-

Are you sure you want to delete this panel?

-

- This action cannot be undone. The panel will be permanently removed from the dashboard. -

-
-
-
- - - - - diff --git a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.spec.ts deleted file mode 100644 index 465984d87..000000000 --- a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { PanelDeleteDialogComponent } from './panel-delete-dialog.component'; - -describe('PanelDeleteDialogComponent', () => { - let component: PanelDeleteDialogComponent; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - - beforeEach(async () => { - mockDashboardsService = { - deleteWidget: vi.fn(), - } as Partial; - - await TestBed.configureTestingModule({ - imports: [PanelDeleteDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionId: 'test-conn', - dashboardId: 'test-dash', - widget: { id: 'test-id', name: 'Test Widget' }, - }, - }, - { provide: MatDialogRef, useValue: { close: vi.fn() } }, - { provide: DashboardsService, useValue: mockDashboardsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PanelDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.stories.ts b/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.stories.ts deleted file mode 100644 index 89def7fe3..000000000 --- a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.stories.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { PanelDeleteDialogComponent } from './panel-delete-dialog.component'; - -const mockDashboardsService: Partial = { - deleteWidget: () => Promise.resolve(null), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/PanelDelete', - component: PanelDeleteDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - connectionId: 'conn-1', - dashboardId: 'dash-1', - widget: { - id: 'widget-1', - position_x: 0, - position_y: 0, - width: 4, - height: 2, - query_id: 'query-1', - dashboard_id: 'dash-1', - }, - }, - }, - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.ts b/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.ts deleted file mode 100644 index d6e7417ba..000000000 --- a/frontend/src/app/components/dashboards/panel-delete-dialog/panel-delete-dialog.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { DashboardsService } from 'src/app/services/dashboards.service'; - -@Component({ - selector: 'app-panel-delete-dialog', - templateUrl: './panel-delete-dialog.component.html', - styleUrls: ['./panel-delete-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], -}) -export class PanelDeleteDialogComponent { - protected submitting = signal(false); - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { connectionId: string; dashboardId: string; widget: DashboardWidget }, - private dialogRef: MatDialogRef, - private _dashboards: DashboardsService, - private angulartics2: Angulartics2, - ) {} - - async onDelete(): Promise { - this.submitting.set(true); - const result = await this._dashboards.deleteWidget( - this.data.connectionId, - this.data.dashboardId, - this.data.widget.id, - ); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: panel deleted successfully', - }); - posthog.capture('Dashboards: panel deleted successfully'); - this.dialogRef.close(true); - } - this.submitting.set(false); - } -} diff --git a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.css b/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.css deleted file mode 100644 index 659332423..000000000 --- a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.css +++ /dev/null @@ -1,69 +0,0 @@ -.panel-form { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 400px; - padding-top: 8px; -} - -@media (width <= 600px) { - .panel-form { - min-width: auto; - } -} - -.full-width { - width: 100%; -} - -.no-queries-message { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 24px; - min-width: 300px; -} - -.no-queries-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: rgba(0, 0, 0, 0.26); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .no-queries-icon { - color: rgba(255, 255, 255, 0.3); - } -} - -.no-queries-message h3 { - margin: 0 0 8px 0; - font-size: 18px; - font-weight: 500; -} - -.no-queries-message p { - margin: 0 0 20px 0; - color: rgba(0, 0, 0, 0.6); - font-size: 14px; -} - -@media (prefers-color-scheme: dark) { - .no-queries-message p { - color: rgba(255, 255, 255, 0.6); - } -} - -.create-option { - color: var(--color-accentedPalette-500); -} - -.create-option mat-icon { - margin-right: 8px; - font-size: 20px; - width: 20px; - height: 20px; -} diff --git a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.html b/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.html deleted file mode 100644 index 23b4d494e..000000000 --- a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.html +++ /dev/null @@ -1,51 +0,0 @@ -
-

{{ isEdit ? 'Edit panel' : 'Add panel' }}

- - - @if (savedQueries().length > 0) { -
- - Saved query - - @for (query of savedQueries(); track query.id) { - {{ query.name }} - } - - - add - Create new query - - - Select a saved query to display in this panel - @if (form.get('query_id')?.hasError('required')) { - Please select a saved query - } - -
- } @else { -
- code -

No saved queries yet

-

Create a saved query first to add it as a panel on this dashboard.

- - add - Create query - -
- } -
- - - - @if (savedQueries().length > 0) { - - } - -
diff --git a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.spec.ts b/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.spec.ts deleted file mode 100644 index 79b6572cf..000000000 --- a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQuery } from 'src/app/models/saved-query'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { PanelEditDialogComponent } from './panel-edit-dialog.component'; - -type PanelEditDialogTestable = PanelEditDialogComponent & { - form: ReturnType; - isEdit: boolean; - onSubmit(): void; -}; - -describe('PanelEditDialogComponent', () => { - let component: PanelEditDialogComponent; - let testable: PanelEditDialogTestable; - let fixture: ComponentFixture; - let mockDashboardsService: Partial; - let mockSavedQueriesService: Partial; - let savedQueriesSignal: WritableSignal; - let mockDialogRef: { close: ReturnType }; - - const mockSavedQueries: SavedQuery[] = [ - { - id: 'query-1', - name: 'Avg Clicks / Short URL', - description: null, - widget_type: 'chart', - chart_type: 'bar', - widget_options: null, - query_text: 'SELECT * FROM stats', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - { - id: 'query-2', - name: 'Total Users', - description: 'Count of all users', - widget_type: 'counter', - chart_type: null, - widget_options: { value_column: 'count' }, - query_text: 'SELECT COUNT(*) as count FROM users', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - ]; - - function setupTestBed(widget: DashboardWidget | null) { - savedQueriesSignal = signal(mockSavedQueries); - mockDialogRef = { close: vi.fn() }; - - mockDashboardsService = { - createWidget: vi.fn().mockResolvedValue({ id: 'new-widget' }), - updateWidget: vi.fn().mockResolvedValue({ id: 'updated-widget' }), - } as Partial; - - mockSavedQueriesService = { - savedQueries: savedQueriesSignal.asReadonly(), - setActiveConnection: vi.fn(), - }; - - return TestBed.configureTestingModule({ - imports: [PanelEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: MAT_DIALOG_DATA, - useValue: { connectionId: 'test-conn', dashboardId: 'test-dash', widget }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: DashboardsService, useValue: mockDashboardsService }, - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - ], - }).compileComponents(); - } - - describe('Add widget mode', () => { - beforeEach(async () => { - await setupTestBed(null); - fixture = TestBed.createComponent(PanelEditDialogComponent); - component = fixture.componentInstance; - testable = component as PanelEditDialogTestable; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should be in add mode when widget is null', () => { - expect(testable.isEdit).toBe(false); - }); - - it('should require query_id when adding widget', () => { - const queryIdControl = testable.form.get('query_id'); - expect(queryIdControl?.hasError('required')).toBe(true); - }); - - it('should call setActiveConnection on init', () => { - expect(mockSavedQueriesService.setActiveConnection).toHaveBeenCalledWith('test-conn'); - }); - - it('should call createWidget with position data when form is submitted', () => { - testable.form.get('query_id')?.setValue('query-1'); - testable.onSubmit(); - - expect(mockDashboardsService.createWidget).toHaveBeenCalledWith('test-conn', 'test-dash', { - query_id: 'query-1', - position_x: 0, - position_y: 0, - width: 4, - height: 4, - }); - }); - - it('should close dialog after successful widget creation', async () => { - testable.form.get('query_id')?.setValue('query-1'); - await testable.onSubmit(); - await fixture.whenStable(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - }); - - describe('Edit widget mode', () => { - const existingWidget: DashboardWidget = { - id: 'widget-123', - position_x: 2, - position_y: 3, - width: 4, - height: 4, - query_id: 'query-1', - dashboard_id: 'test-dash', - }; - - beforeEach(async () => { - await setupTestBed(existingWidget); - fixture = TestBed.createComponent(PanelEditDialogComponent); - component = fixture.componentInstance; - testable = component as PanelEditDialogTestable; - fixture.detectChanges(); - }); - - it('should be in edit mode when widget is provided', () => { - expect(testable.isEdit).toBe(true); - }); - - it('should initialize form with existing query_id', () => { - expect(testable.form.get('query_id')?.value).toBe('query-1'); - }); - - it('should call updateWidget when form is submitted', () => { - testable.form.get('query_id')?.setValue('query-2'); - testable.onSubmit(); - - expect(mockDashboardsService.updateWidget).toHaveBeenCalledWith('test-conn', 'test-dash', 'widget-123', { - query_id: 'query-2', - }); - }); - - it('should close dialog after successful widget update', async () => { - testable.form.get('query_id')?.setValue('query-2'); - await testable.onSubmit(); - await fixture.whenStable(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - }); - - describe('Widget without linked query', () => { - const widgetWithoutQuery: DashboardWidget = { - id: 'widget-456', - position_x: 0, - position_y: 0, - width: 4, - height: 4, - query_id: null, - dashboard_id: 'test-dash', - }; - - beforeEach(async () => { - await setupTestBed(widgetWithoutQuery); - fixture = TestBed.createComponent(PanelEditDialogComponent); - component = fixture.componentInstance; - testable = component as PanelEditDialogTestable; - fixture.detectChanges(); - }); - - it('should initialize form with empty query_id', () => { - expect(testable.form.get('query_id')?.value).toBe(''); - }); - - it('should show validation error for empty query_id', () => { - expect(testable.form.get('query_id')?.hasError('required')).toBe(true); - }); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.ts b/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.ts deleted file mode 100644 index 9f4c889ae..000000000 --- a/frontend/src/app/components/dashboards/panel-edit-dialog/panel-edit-dialog.component.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, Inject, OnInit, signal } from '@angular/core'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSelectModule } from '@angular/material/select'; -import { Router, RouterModule } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; - -@Component({ - selector: 'app-panel-edit-dialog', - templateUrl: './panel-edit-dialog.component.html', - styleUrls: ['./panel-edit-dialog.component.css'], - imports: [ - CommonModule, - ReactiveFormsModule, - RouterModule, - MatDialogModule, - MatButtonModule, - MatDividerModule, - MatFormFieldModule, - MatIconModule, - MatSelectModule, - ], -}) -export class PanelEditDialogComponent implements OnInit { - protected submitting = signal(false); - protected form!: FormGroup; - protected isEdit: boolean; - - protected savedQueries = computed(() => this._savedQueries.savedQueries()); - - constructor( - @Inject(MAT_DIALOG_DATA) - public data: { connectionId: string; dashboardId: string; widget: DashboardWidget | null }, - public dialogRef: MatDialogRef, - private _dashboards: DashboardsService, - private _savedQueries: SavedQueriesService, - private _router: Router, - private fb: FormBuilder, - private angulartics2: Angulartics2, - ) { - this.isEdit = !!data.widget; - } - - ngOnInit(): void { - this._savedQueries.setActiveConnection(this.data.connectionId); - - const widget = this.data.widget; - - this.form = this.fb.group({ - query_id: [widget?.query_id || '', [Validators.required]], - }); - } - - async onSubmit(): Promise { - if (this.form.invalid) return; - - this.submitting.set(true); - const formValue = this.form.value; - - if (this.isEdit) { - const payload = { - query_id: formValue.query_id, - }; - - const result = await this._dashboards.updateWidget( - this.data.connectionId, - this.data.dashboardId, - this.data.widget!.id, - payload, - ); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: panel updated successfully', - }); - posthog.capture('Dashboards: panel updated successfully'); - this.dialogRef.close(true); - } - } else { - const payload = { - query_id: formValue.query_id, - position_x: 0, - position_y: 0, - width: 4, - height: 4, - }; - - const result = await this._dashboards.createWidget(this.data.connectionId, this.data.dashboardId, payload); - if (result) { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: panel created successfully', - }); - posthog.capture('Dashboards: panel created successfully'); - this.dialogRef.close(true); - } - } - this.submitting.set(false); - } - - navigateToCreateQuery(): void { - this.dialogRef.close(); - this._router.navigate(['/panels', this.data.connectionId, 'new']); - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.css b/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.css deleted file mode 100644 index f0abf2ab4..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.css +++ /dev/null @@ -1,58 +0,0 @@ -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -.loading-container, -.error-container, -.no-query-container, -.no-data-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; -} - -.error-message { - color: var(--color-warnPalette-500); - margin: 0; -} - -@media (prefers-color-scheme: dark) { - .error-message { - color: var(--color-warnDarkPalette-500); - } -} - -.no-query-container p, -.no-data-container p { - margin: 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-query-container p, - .no-data-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.hint { - font-size: 12px; - margin-top: 4px !important; -} - -.chart-container { - flex: 1; - position: relative; - min-height: 0; -} - -.chart-container canvas { - width: 100% !important; - height: 100% !important; -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.html b/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.html deleted file mode 100644 index 3daf9afaa..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.html +++ /dev/null @@ -1,14 +0,0 @@ -@if (chartData()) { -
- - -
-} @else { -
-

No data available

-
-} diff --git a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.spec.ts b/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.spec.ts deleted file mode 100644 index 7c5d4652c..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; -import { ChartPanelComponent } from './chart-panel.component'; - -describe('ChartPanelComponent', () => { - let component: ChartPanelComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ChartPanelComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideHttpClientTesting(), provideCharts(withDefaultRegisterables())], - }).compileComponents(); - - fixture = TestBed.createComponent(ChartPanelComponent); - component = fixture.componentInstance; - component.widget = { - id: 'test-id', - position_x: 0, - position_y: 0, - width: 4, - height: 4, - query_id: 'test-query', - dashboard_id: 'test-dashboard', - }; - component.connectionId = 'test-conn'; - component.preloadedQuery = { - id: 'test-query', - name: 'Test Query', - description: null, - widget_type: 'chart', - chart_type: 'bar', - widget_options: { label_column: 'name', value_column: 'count' }, - query_text: 'SELECT * FROM stats', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - component.preloadedData = [ - { name: 'A', count: 10 }, - { name: 'B', count: 20 }, - ]; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display chart when data is provided', () => { - const compiled = fixture.nativeElement; - expect(compiled.querySelector('.chart-container')).toBeTruthy(); - }); - - it('should compute chart data from preloaded data', () => { - const chartData = component['chartData'](); - expect(chartData).toBeTruthy(); - expect(chartData?.labels).toEqual(['A', 'B']); - expect(chartData?.datasets[0].data).toEqual([10, 20]); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.stories.ts b/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.stories.ts deleted file mode 100644 index 2a5cbaa95..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.stories.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ChartPanelComponent } from './chart-panel.component'; - -const mockWidget = { - id: 'widget-2', - position_x: 0, - position_y: 0, - width: 6, - height: 4, - query_id: 'query-2', - dashboard_id: 'dashboard-1', -}; - -const mockQuery = { - id: 'query-2', - name: 'Monthly Revenue', - description: null, - widget_type: 'chart' as const, - chart_type: 'bar' as const, - widget_options: { - label_column: 'month', - value_column: 'revenue', - }, - query_text: 'SELECT month, revenue FROM monthly_stats', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', -}; - -const mockData = [ - { month: 'Jan', revenue: 12000 }, - { month: 'Feb', revenue: 15000 }, - { month: 'Mar', revenue: 18000 }, - { month: 'Apr', revenue: 14000 }, - { month: 'May', revenue: 21000 }, - { month: 'Jun', revenue: 19000 }, -]; - -const meta: Meta = { - title: 'Dashboard Panels/Chart', - component: ChartPanelComponent, - args: { - widget: mockWidget as any, - connectionId: 'conn-1', - preloadedQuery: mockQuery as any, - preloadedData: mockData, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const LineChart: Story = { - args: { - preloadedQuery: { - ...mockQuery, - chart_type: 'line', - } as any, - }, -}; - -export const PieChart: Story = { - args: { - preloadedQuery: { - ...mockQuery, - chart_type: 'pie', - } as any, - }, -}; - -export const NoData: Story = { - args: { - preloadedQuery: null, - preloadedData: [], - }, -}; diff --git a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.ts deleted file mode 100644 index 7937d77be..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/chart-panel/chart-panel.component.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, Input, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js'; -import 'chartjs-adapter-date-fns'; -import { BaseChartDirective } from 'ng2-charts'; -import { - buildChartData, - buildChartOptions, - buildDataLabelsPlugin, - getMappedChartType, -} from 'src/app/lib/chart-config.helper'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { ChartWidgetOptions, SavedQuery } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-chart-panel', - templateUrl: './chart-panel.component.html', - styleUrls: ['./chart-panel.component.css'], - imports: [CommonModule, BaseChartDirective, MatProgressSpinnerModule], -}) -export class ChartPanelComponent implements OnInit { - @Input({ required: true }) widget!: DashboardWidget; - @Input({ required: true }) connectionId!: string; - @Input() preloadedQuery: SavedQuery | null = null; - @Input() preloadedData: Record[] = []; - - protected data = signal[]>([]); - protected savedQuery = signal(null); - - protected chartData = computed | null>(() => { - const data = this.data(); - const query = this.savedQuery(); - if (!data.length || !query) return null; - - const chartType = query.chart_type || 'bar'; - const widgetOptions = this._resolveWidgetOptions(query, data); - - return buildChartData(data, chartType, widgetOptions); - }); - - protected chartOptions = computed(() => { - const query = this.savedQuery(); - const data = this.data(); - if (!query || !data.length) return this._getDefaultOptions(); - - const chartType = query.chart_type || 'bar'; - const widgetOptions = this._resolveWidgetOptions(query, data); - - return buildChartOptions(chartType, widgetOptions); - }); - - protected chartPlugins = computed(() => { - const query = this.savedQuery(); - if (!query) return []; - - const widgetOptions = (query.widget_options ?? {}) as unknown as ChartWidgetOptions; - return [buildDataLabelsPlugin(widgetOptions)]; - }); - - ngOnInit(): void { - if (this.preloadedQuery) { - this.savedQuery.set(this.preloadedQuery); - } - if (this.preloadedData.length > 0) { - this.data.set(this.preloadedData); - } - } - - get mappedChartType(): ChartJsType { - const query = this.savedQuery(); - const chartType = query?.chart_type || 'bar'; - const widgetOptions = (query?.widget_options ?? {}) as unknown as ChartWidgetOptions; - return getMappedChartType(chartType, widgetOptions); - } - - private _resolveWidgetOptions(query: SavedQuery, data: Record[]): ChartWidgetOptions { - const raw = (query.widget_options ?? {}) as Partial; - - // Fallback label/value columns from data keys - const labelColumn = raw.label_column || (data.length ? Object.keys(data[0])[0] : '') || ''; - const valueColumn = - raw.value_column || (data.length ? Object.keys(data[0])[1] || Object.keys(data[0])[0] : '') || ''; - - return { - ...raw, - label_column: labelColumn, - value_column: valueColumn, - }; - } - - private _getDefaultOptions(): ChartConfiguration['options'] { - return { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'top', - }, - }, - }; - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.css b/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.css deleted file mode 100644 index 76eb24781..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.css +++ /dev/null @@ -1,69 +0,0 @@ -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -.loading-container, -.error-container, -.no-query-container, -.no-data-container, -.counter-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; -} - -.error-message { - color: var(--color-warnPalette-500); - margin: 0; -} - -@media (prefers-color-scheme: dark) { - .error-message { - color: var(--color-warnDarkPalette-500); - } -} - -.no-query-container p, -.no-data-container p { - margin: 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-query-container p, - .no-data-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.hint { - font-size: 12px; - margin-top: 4px !important; -} - -.counter-value { - font-size: 48px; - font-weight: 700; - line-height: 1; - color: var(--color-accentedPalette-500); -} - -.counter-label { - font-size: 14px; - color: rgba(0, 0, 0, 0.54); - margin-top: 8px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -@media (prefers-color-scheme: dark) { - .counter-label { - color: rgba(255, 255, 255, 0.54); - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.html b/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.html deleted file mode 100644 index 9572b5838..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.html +++ /dev/null @@ -1,12 +0,0 @@ -@if (counterValue() !== null) { -
- {{ counterValue() }} - @if (label()) { - {{ label() }} - } -
-} @else { -
-

No data available

-
-} diff --git a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.spec.ts b/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.spec.ts deleted file mode 100644 index 96e19c423..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CounterPanelComponent } from './counter-panel.component'; - -describe('CounterPanelComponent', () => { - let component: CounterPanelComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CounterPanelComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideHttpClientTesting()], - }).compileComponents(); - - fixture = TestBed.createComponent(CounterPanelComponent); - component = fixture.componentInstance; - component.widget = { - id: 'test-id', - position_x: 0, - position_y: 0, - width: 2, - height: 2, - query_id: 'test-query', - dashboard_id: 'test-dashboard', - }; - component.connectionId = 'test-conn'; - component.preloadedQuery = { - id: 'test-query', - name: 'Test Counter', - description: null, - widget_type: 'counter', - chart_type: null, - widget_options: { value_column: 'total' }, - query_text: 'SELECT COUNT(*) as total FROM users', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - component.preloadedData = [{ total: 42 }]; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display counter value from preloaded data', () => { - const compiled = fixture.nativeElement; - expect(compiled.querySelector('.counter-container')).toBeTruthy(); - expect(compiled.querySelector('.counter-value').textContent).toContain('42'); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.stories.ts b/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.stories.ts deleted file mode 100644 index b6281bd41..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.stories.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CounterPanelComponent } from './counter-panel.component'; - -const mockWidget = { - id: 'widget-1', - position_x: 0, - position_y: 0, - width: 4, - height: 2, - query_id: 'query-1', - dashboard_id: 'dashboard-1', -}; - -const mockQuery = { - id: 'query-1', - name: 'Total Users', - description: null, - widget_type: 'counter' as const, - chart_type: null, - widget_options: null, - query_text: 'SELECT COUNT(*) as total FROM users', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', -}; - -const meta: Meta = { - title: 'Dashboard Panels/Counter', - component: CounterPanelComponent, - args: { - widget: mockWidget as any, - connectionId: 'conn-1', - preloadedQuery: mockQuery as any, - preloadedData: [{ total: 1234 }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const LargeNumber: Story = { - args: { - preloadedData: [{ total: 1500000 }], - }, -}; - -export const NoData: Story = { - args: { - preloadedQuery: null, - preloadedData: [], - }, -}; diff --git a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.ts deleted file mode 100644 index df28c1c01..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/counter-panel/counter-panel.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, Input, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQuery } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-counter-panel', - templateUrl: './counter-panel.component.html', - styleUrls: ['./counter-panel.component.css'], - imports: [CommonModule, MatProgressSpinnerModule], -}) -export class CounterPanelComponent implements OnInit { - @Input({ required: true }) widget!: DashboardWidget; - @Input({ required: true }) connectionId!: string; - @Input() preloadedQuery: SavedQuery | null = null; - @Input() preloadedData: Record[] = []; - - protected data = signal[]>([]); - protected savedQuery = signal(null); - - protected counterValue = computed(() => { - const data = this.data(); - const query = this.savedQuery(); - if (!data.length || !query) return null; - - const valueColumn = this._getValueColumn(query, data); - if (!valueColumn) return null; - - const value = data[0][valueColumn]; - if (typeof value === 'number') { - return this._formatNumber(value); - } - return String(value); - }); - - protected label = computed(() => { - const data = this.data(); - const query = this.savedQuery(); - if (!data.length || !query) return ''; - - const labelColumn = query.widget_options?.['label_column'] as string | undefined; - if (labelColumn && data[0][labelColumn]) { - return String(data[0][labelColumn]); - } - - const valueColumn = this._getValueColumn(query, data); - return valueColumn || ''; - }); - - ngOnInit(): void { - if (this.preloadedQuery) { - this.savedQuery.set(this.preloadedQuery); - } - if (this.preloadedData.length > 0) { - this.data.set(this.preloadedData); - } - } - - private _getValueColumn(query: SavedQuery, data: Record[]): string | null { - const valueCol = query.widget_options?.['value_column'] as string | undefined; - if (valueCol) return valueCol; - - if (!data.length) return null; - return Object.keys(data[0])[0] || null; - } - - private _formatNumber(value: number): string { - if (Math.abs(value) >= 1000000) { - return (value / 1000000).toFixed(1) + 'M'; - } else if (Math.abs(value) >= 1000) { - return (value / 1000).toFixed(1) + 'K'; - } - return value.toLocaleString(); - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.css b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.css deleted file mode 100644 index 7f17a6816..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.css +++ /dev/null @@ -1,23 +0,0 @@ -:host { - display: block; - width: 100%; - height: 100%; -} - -.panel-loading { - display: flex; - align-items: center; - justify-content: center; - height: 100%; -} - -.panel-error { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--color-warnDarkPalette-500); - font-size: 14px; - text-align: center; - padding: 16px; -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.html b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.html deleted file mode 100644 index cd94183fd..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.html +++ /dev/null @@ -1,47 +0,0 @@ -@if (loading()) { -
- -
-} @else if (error()) { -
- {{ error() }} -
-} @else if (savedQuery()) { - @switch (savedQuery()!.widget_type) { - @case ('chart') { - - - } - @case ('table') { - - - } - @case ('counter') { - - - } - @case ('text') { - - - } - @default { -
- Unknown panel type: {{ savedQuery()!.widget_type }} -
- } - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.stories.ts b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.stories.ts deleted file mode 100644 index 1e099e6b8..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.stories.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { DashboardPanelComponent } from './dashboard-panel.component'; - -const mockWidget = { - id: 'widget-5', - position_x: 0, - position_y: 0, - width: 6, - height: 4, - query_id: 'query-5', - dashboard_id: 'dashboard-1', -}; - -const mockSavedQueriesService: Partial = { - fetchSavedQuery: () => - Promise.resolve({ - id: 'query-5', - name: 'Total Users', - description: null, - widget_type: 'counter' as const, - chart_type: null, - widget_options: null, - query_text: 'SELECT COUNT(*) as total FROM users', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', - }), - executeSavedQuery: () => - Promise.resolve({ - query_id: 'query-5', - query_name: 'Total Users', - data: [{ total: 1234 }], - execution_time_ms: 42, - }), -}; - -const meta: Meta = { - title: 'Dashboard Panels/DashboardPanel', - component: DashboardPanelComponent, - args: { - widget: mockWidget as any, - connectionId: 'conn-1', - }, - decorators: [ - applicationConfig({ - providers: [{ provide: SavedQueriesService, useValue: mockSavedQueriesService }], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const NoQuery: Story = { - args: { - widget: { - ...mockWidget, - query_id: null, - } as any, - }, -}; diff --git a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts deleted file mode 100644 index 6b80ffb3a..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, effect, Input, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQuery } from 'src/app/models/saved-query'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; -import { ChartPanelComponent } from '../chart-panel/chart-panel.component'; -import { CounterPanelComponent } from '../counter-panel/counter-panel.component'; -import { TablePanelComponent } from '../table-panel/table-panel.component'; -import { TextPanelComponent } from '../text-panel/text-panel.component'; - -@Component({ - selector: 'app-dashboard-panel', - templateUrl: './dashboard-panel.component.html', - styleUrls: ['./dashboard-panel.component.css'], - imports: [ - CommonModule, - MatProgressSpinnerModule, - ChartPanelComponent, - CounterPanelComponent, - TablePanelComponent, - TextPanelComponent, - ], -}) -export class DashboardPanelComponent { - @Input({ required: true }) widget!: DashboardWidget; - @Input({ required: true }) connectionId!: string; - - private _savedQueries = inject(SavedQueriesService); - - protected loading = signal(true); - protected error = signal(null); - protected queryData = signal[]>([]); - - // Public signal for parent to access widget name - public savedQuery = signal(null); - - constructor() { - effect(() => { - if (this.widget?.query_id) { - this._loadData(); - } else { - this.loading.set(false); - this.error.set('No query linked to this panel'); - } - }); - } - - private async _loadData(): Promise { - if (!this.widget.query_id) { - this.loading.set(false); - this.error.set('No query linked to this panel'); - return; - } - - this.loading.set(true); - this.error.set(null); - - try { - const [query, result] = await Promise.all([ - this._savedQueries.fetchSavedQuery(this.connectionId, this.widget.query_id), - this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id), - ]); - - this.savedQuery.set(query); - if (result) { - this.queryData.set(result.data); - } - this.loading.set(false); - } catch (err: unknown) { - const error = err as { error?: { message?: string } }; - this.error.set(error?.error?.message || 'Failed to load data'); - this.loading.set(false); - } - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.css b/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.css deleted file mode 100644 index 20b19a97d..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.css +++ /dev/null @@ -1,75 +0,0 @@ -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -.loading-container, -.error-container, -.no-query-container, -.no-data-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; -} - -.error-message { - color: var(--color-warnPalette-500); - margin: 0; -} - -@media (prefers-color-scheme: dark) { - .error-message { - color: var(--color-warnDarkPalette-500); - } -} - -.no-query-container p, -.no-data-container p { - margin: 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-query-container p, - .no-data-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.hint { - font-size: 12px; - margin-top: 4px !important; -} - -.table-container { - flex: 1; - overflow: auto; -} - -.table-container table { - width: 100%; -} - -.table-container th { - background: rgba(0, 0, 0, 0.04); - font-weight: 600; - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .table-container th { - background: rgba(255, 255, 255, 0.04); - } -} - -.table-container td { - white-space: nowrap; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.html b/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.html deleted file mode 100644 index b9eea58c7..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.html +++ /dev/null @@ -1,18 +0,0 @@ -@if (data().length > 0) { -
- - @for (column of columns(); track column) { - - - - - } - - -
{{ column }}{{ row[column] }}
-
-} @else { -
-

No data available

-
-} diff --git a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.spec.ts b/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.spec.ts deleted file mode 100644 index 3d41328f5..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TablePanelComponent } from './table-panel.component'; - -describe('TablePanelComponent', () => { - let component: TablePanelComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TablePanelComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideHttpClientTesting()], - }).compileComponents(); - - fixture = TestBed.createComponent(TablePanelComponent); - component = fixture.componentInstance; - component.widget = { - id: 'test-id', - position_x: 0, - position_y: 0, - width: 4, - height: 4, - query_id: 'test-query', - dashboard_id: 'test-dashboard', - }; - component.connectionId = 'test-conn'; - component.preloadedQuery = { - id: 'test-query', - name: 'Test Table', - description: null, - widget_type: 'table', - chart_type: null, - widget_options: null, - query_text: 'SELECT * FROM users', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - component.preloadedData = [ - { id: 1, name: 'John', email: 'john@example.com' }, - { id: 2, name: 'Jane', email: 'jane@example.com' }, - ]; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display table with preloaded data', () => { - const compiled = fixture.nativeElement; - expect(compiled.querySelector('.table-container')).toBeTruthy(); - expect(compiled.querySelector('table')).toBeTruthy(); - }); - - it('should compute columns from data', () => { - const columns = component['columns'](); - expect(columns).toEqual(['id', 'name', 'email']); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.stories.ts b/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.stories.ts deleted file mode 100644 index 252e1f23e..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.stories.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TablePanelComponent } from './table-panel.component'; - -const mockWidget = { - id: 'widget-3', - position_x: 0, - position_y: 0, - width: 8, - height: 4, - query_id: 'query-3', - dashboard_id: 'dashboard-1', -}; - -const mockQuery = { - id: 'query-3', - name: 'Recent Orders', - description: null, - widget_type: 'table' as const, - chart_type: null, - widget_options: null, - query_text: 'SELECT id, customer, amount, status FROM orders LIMIT 10', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', -}; - -const mockData = [ - { id: 1, customer: 'Alice', amount: 250.0, status: 'completed' }, - { id: 2, customer: 'Bob', amount: 120.5, status: 'pending' }, - { id: 3, customer: 'Charlie', amount: 340.0, status: 'completed' }, - { id: 4, customer: 'Diana', amount: 89.99, status: 'cancelled' }, - { id: 5, customer: 'Eve', amount: 560.0, status: 'completed' }, -]; - -const meta: Meta = { - title: 'Dashboard Panels/Table', - component: TablePanelComponent, - args: { - widget: mockWidget as any, - connectionId: 'conn-1', - preloadedQuery: mockQuery as any, - preloadedData: mockData, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const NoData: Story = { - args: { - preloadedQuery: null, - preloadedData: [], - }, -}; diff --git a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.ts deleted file mode 100644 index 90fe63cf3..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/table-panel/table-panel.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, Input, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTableModule } from '@angular/material/table'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQuery } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-table-panel', - templateUrl: './table-panel.component.html', - styleUrls: ['./table-panel.component.css'], - imports: [CommonModule, MatTableModule, MatProgressSpinnerModule], -}) -export class TablePanelComponent implements OnInit { - @Input({ required: true }) widget!: DashboardWidget; - @Input({ required: true }) connectionId!: string; - @Input() preloadedQuery: SavedQuery | null = null; - @Input() preloadedData: Record[] = []; - - protected data = signal[]>([]); - - protected columns = computed(() => { - const data = this.data(); - if (!data.length) return []; - return Object.keys(data[0]); - }); - - ngOnInit(): void { - if (this.preloadedData.length > 0) { - this.data.set(this.preloadedData); - } - } -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.css b/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.css deleted file mode 100644 index 83d799b63..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.css +++ /dev/null @@ -1,94 +0,0 @@ -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; -} - -.text-container { - flex: 1; - overflow: auto; -} - -.text-container ::ng-deep { - h1, - h2, - h3, - h4, - h5, - h6 { - margin-top: 0; - margin-bottom: 8px; - } - - p { - margin: 0 0 8px 0; - } - - p:last-child { - margin-bottom: 0; - } - - ul, - ol { - margin: 0 0 8px 0; - padding-left: 20px; - } - - a { - color: var(--color-accentedPalette-500); - } - - code { - background: rgba(0, 0, 0, 0.05); - padding: 2px 6px; - border-radius: 4px; - font-family: monospace; - } - - pre { - background: rgba(0, 0, 0, 0.05); - padding: 12px; - border-radius: 4px; - overflow: auto; - } - - pre code { - background: none; - padding: 0; - } -} - -@media (prefers-color-scheme: dark) { - .text-container ::ng-deep { - code, - pre { - background: rgba(255, 255, 255, 0.05); - } - } -} - -.no-content-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - text-align: center; -} - -.no-content-container p { - margin: 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-content-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.hint { - font-size: 12px; - margin-top: 4px !important; -} diff --git a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.html b/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.html deleted file mode 100644 index c2380e8ce..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.html +++ /dev/null @@ -1,10 +0,0 @@ -@if (textContent()) { -
- -
-} @else { -
-

No content

-

Edit this panel to add text content.

-
-} diff --git a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.spec.ts b/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.spec.ts deleted file mode 100644 index 744eaceca..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { MarkdownModule } from 'ngx-markdown'; -import { TextPanelComponent } from './text-panel.component'; - -describe('TextPanelComponent', () => { - let component: TextPanelComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextPanelComponent, BrowserAnimationsModule, MarkdownModule.forRoot()], - }).compileComponents(); - - fixture = TestBed.createComponent(TextPanelComponent); - component = fixture.componentInstance; - component.widget = { - id: 'test-id', - position_x: 0, - position_y: 0, - width: 4, - height: 4, - query_id: null, - dashboard_id: 'test-dashboard', - }; - component.preloadedQuery = { - id: 'test-query', - name: 'Test Text', - description: null, - widget_type: 'text', - chart_type: null, - widget_options: { text_content: '# Hello World' }, - query_text: '', - connection_id: 'test-conn', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should compute text content from preloaded query', () => { - const textContent = component['textContent'](); - expect(textContent).toBe('# Hello World'); - }); -}); diff --git a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.stories.ts b/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.stories.ts deleted file mode 100644 index 803d505ab..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.stories.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TextPanelComponent } from './text-panel.component'; - -const mockWidget = { - id: 'widget-4', - position_x: 0, - position_y: 0, - width: 4, - height: 2, - query_id: 'query-4', - dashboard_id: 'dashboard-1', -}; - -const mockQuery = { - id: 'query-4', - name: 'Dashboard Info', - description: null, - widget_type: 'text' as const, - chart_type: null, - widget_options: { - text_content: - '## Welcome\n\nThis dashboard shows key metrics for the **Q1 2024** report.\n\n- Total revenue\n- Active users\n- Order status', - }, - query_text: '', - connection_id: 'conn-1', - created_at: '2024-01-01', - updated_at: '2024-01-01', -}; - -const meta: Meta = { - title: 'Dashboard Panels/Text', - component: TextPanelComponent, - args: { - widget: mockWidget as any, - preloadedQuery: mockQuery as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const NoContent: Story = { - args: { - preloadedQuery: null, - }, -}; diff --git a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.ts deleted file mode 100644 index 43f0679de..000000000 --- a/frontend/src/app/components/dashboards/panel-renderers/text-panel/text-panel.component.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, Input, signal } from '@angular/core'; -import { MarkdownModule } from 'ngx-markdown'; -import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQuery } from 'src/app/models/saved-query'; - -@Component({ - selector: 'app-text-panel', - templateUrl: './text-panel.component.html', - styleUrls: ['./text-panel.component.css'], - imports: [CommonModule, MarkdownModule], -}) -export class TextPanelComponent { - @Input({ required: true }) widget!: DashboardWidget; - @Input() preloadedQuery: SavedQuery | null = null; - - protected textContent = computed(() => { - const query = this.preloadedQuery; - if (!query) return ''; - return (query.widget_options?.['text_content'] as string) || ''; - }); -} diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.css b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.css deleted file mode 100644 index b909bbf76..000000000 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.css +++ /dev/null @@ -1,358 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -:host app-alert:empty { - display: none; -} - -.page { - display: flex; - height: 100%; - margin: 16px 0; -} - -@media (width <= 600px) { - .page { - margin: 8px 0 0; - } -} - -.wrapper { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - width: 100vw; -} - -@media (width <= 600px) { - .wrapper { - padding: 36px 9vw 16px; - width: 100vw; - } -} - -.wrapper_shifted { - margin-left: calc(clamp(-400px, -22vw, -200px) - 24px); -} - -.row-edit-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.row-edit-header__main { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; -} - -.row-edit-header__back { - flex: 0 0 auto; -} - -.row-edit-header__text { - display: flex; - flex-direction: column; - line-height: 1.2; - min-width: 0; -} - -.row-edit-header__title { - font-size: 18px; - font-weight: 600; -} - -.row-edit-header__subtitle { - font-size: 12px; - font-weight: 500; - opacity: 0.6; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@media (width <= 600px) { - .row-edit-header { - width: 100%; - } -} - -.row-actions { - display: flex; - align-items: center; -} - -.custom-actions_desktop { - display: flex; - align-items: center; - gap: 4px; -} - -@media (width <= 600px) { - .custom-actions_desktop { - display: none; - } -} - -.custom-actions_mobile { - display: none; -} - -@media (width <= 600px) { - .custom-actions_mobile { - display: flex; - flex-wrap: wrap; - gap: 8px; - } -} - -.form { - display: flex; - flex-direction: column; - margin: 24px auto 40px; - max-width: 520px; - min-width: 300px; - width: 100%; -} - -/* @media (width <= 600px) { - .form { - width: 96%; - } -} */ - -.related-views { - position: absolute; - top: calc(52px + 40px); - right: 0; - display: flex; - flex-direction: column; - margin: 0 auto 0; - height: calc(100vh - 52px - 64px - 96px); - max-width: 520px; - min-width: 300px; - overflow: auto; - padding-right: 20px; - width: max(calc(45vw - 260px), 2%); -} - -@media (width <= 1280px) { - .related-views { - position: initial; - height: auto; - margin: 12px 0 4px; - padding: 4px 2px; - width: 100%; - } - - .related-views__title { - padding: 0 10px; - } -} - -.related-records__table-name { - flex: 1 1 auto; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - margin-right: 8px; -} - -.related-records__table-name-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.related-records__actions { - flex: 0 0 auto; - justify-content: flex-end; - /* Stay clickable even when the panel is disabled (no related records). */ - pointer-events: auto; -} - -/* Keep the action icons fully dark in every panel state (enabled or - disabled), instead of Material's lighter default icon color. */ -.related-records__actions .mat-icon { - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .related-records__actions .mat-icon { - color: rgba(255, 255, 255, 0.87); - } -} - -/* Keep header content legible when the panel is disabled (no related - records) — disabling only blocks expand/collapse, not the actions. */ -.related-records__panel ::ng-deep .mat-expansion-panel-header[aria-disabled='true'] { - color: inherit; - cursor: default; -} - -.related-records__empty-mark { - flex: 0 0 auto; - font-size: 11px; - line-height: 16px; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 1px 6px; - border-radius: 8px; - color: rgba(0, 0, 0, 0.5); - background: rgba(0, 0, 0, 0.06); -} - -.related-records__add-button { - flex: 0 0 auto; -} - -@media (prefers-color-scheme: dark) { - .related-records__empty-mark { - color: rgba(255, 255, 255, 0.6); - background: rgba(255, 255, 255, 0.1); - } -} - -.related-record { - --mat-list-list-item-two-line-container-height: 60px; -} - -.related-record ::ng-deep .mdc-list-item__primary-text::before { - height: 24px; -} - -.related-records__panel ::ng-deep .mat-expansion-panel-body { - padding: 0 8px 16px; -} - -.related-record__fields { - margin-left: -4px; -} - -.related-record__field { - margin-left: 8px; -} - -.related-record__fieldName { - margin-right: 4px; -} - -.widget { - display: block; -} - -.widget-info { - margin-left: auto; - margin-top: 12px; -} - -.widget-info_centered { - align-self: center; - margin-top: -18px; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - /* justify-content: space-between; */ - background-color: var(--background-color); - box-shadow: var(--shadow); - height: 64px; - padding: 0 max(calc(50vw - 260px), 2%); - width: 100vw; -} - -.actions-box { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - width: 100%; -} - -.wrapper_shifted .actions { - left: calc(clamp(-400px, -22vw, -200px)); -} - -.wrapper_shifted .actions-box { - margin-left: -24px; -} - -@media (prefers-color-scheme: dark) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.5), 0 2px 2px 0 rgba(0, 0, 0, 0.64), 0 1px 5px 0 rgba(0, 0, 0, 0.85); - --background-color: var(--surface-dark-color); - } -} - -@media (prefers-color-scheme: light) { - .actions { - --shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); - --background-color: #fff; - } -} - -@media (width <= 600px) { - .wrapper { - padding-bottom: 132px; - } - - .actions { - position: fixed; - left: 0; - right: 0; - bottom: 0; - width: 100%; - height: auto; - min-height: 0; - margin: 0; - padding: 8px 16px max(8px, env(safe-area-inset-bottom, 0px)); - box-sizing: border-box; - border-top: 1px solid rgba(0, 0, 0, 0.08); - z-index: 100; - } - - .wrapper_shifted .actions { - left: 0; - } - - .actions-box { - flex-direction: row; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 8px; - width: 100%; - margin-left: 0; - } - - .actions__continue { - margin-right: auto !important; - margin-left: 0 !important; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .actions { - border-top-color: rgba(255, 255, 255, 0.1); - } -} - -.actions__continue { - margin-right: auto; - margin-left: 0; -} - -.error-details { - margin-top: 8px; -} diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html deleted file mode 100644 index ee21e4224..000000000 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.html +++ /dev/null @@ -1,228 +0,0 @@ - - - - - -
- {{serverError.abstract}} -

{{serverError.details}}

-
- -

{{serverError.abstract}}

-
- -
- -
-
-
-
- - arrow_back - -
- {{ pageTitle }} - {{ dispalyTableName }} -
-
-
- - difference - - -
-
- -
-
- -
-
-
-
- - - -
-
-
-
- -
- -
- - - - -
-
-
- -
-
- - - - - - - - - -
-
-
-
- - -
- diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.spec.ts b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.spec.ts deleted file mode 100644 index 1e00e6f54..000000000 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.spec.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { Connection, ConnectionType, DBtype } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DbTableRowEditComponent } from './db-table-row-edit.component'; - -describe('DbTableRowEditComponent', () => { - let component: DbTableRowEditComponent; - let fixture: ComponentFixture; - let _tablesService: TablesService; - let connectionsService: ConnectionsService; - - beforeEach(async () => { - const matSnackBarSpy = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot(), DbTableRowEditComponent], - providers: [provideHttpClient(), provideRouter([]), { provide: MatSnackBar, useValue: matSnackBarSpy }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DbTableRowEditComponent); - component = fixture.componentInstance; - _tablesService = TestBed.inject(TablesService); - connectionsService = TestBed.inject(ConnectionsService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set connection id', () => { - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('12345678'); - - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.connectionID).toEqual('12345678'); - }); - - it('should set structure — define: relation between column name and type, required columns', async () => { - component.tableForeignKeys = [ - { - referenced_column_name: 'Id', - referenced_table_name: 'Products', - constraint_name: 'Orders_ibfk_1', - column_name: 'ProductId', - }, - { - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - constraint_name: 'Orders_ibfk_2', - column_name: 'CustomerId', - }, - ]; - - const fakeProduct_categories = { - column_name: 'product_categories', - column_default: null, - data_type: 'enum', - data_type_params: ['food', 'drinks', 'cleaning'], - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const fakeCustomer_categories = { - column_name: 'customer_categories', - column_default: null, - data_type: 'enum', - data_type_params: ['manager', 'seller'], - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const fakeCustomerId = { - column_name: 'CustomerId', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: false, - character_maximum_length: null, - }; - - const fakeProductId = { - column_name: 'ProductId', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: false, - character_maximum_length: null, - }; - - const fakeBool = { - column_name: 'bool', - column_default: null, - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }; - - const fakeFloat = { - column_name: 'float', - column_default: null, - data_type: 'float', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 102, - }; - - const fakeStructure = [ - fakeProduct_categories, - fakeCustomer_categories, - fakeCustomerId, - fakeProductId, - fakeBool, - fakeFloat, - ]; - - component.setRowStructure(fakeStructure); - - expect(component.tableRowRequiredValues).toEqual({ - product_categories: false, - customer_categories: false, - CustomerId: true, - ProductId: true, - bool: false, - float: false, - }); - expect(component.tableRowStructure).toEqual({ - product_categories: fakeProduct_categories, - customer_categories: fakeCustomer_categories, - CustomerId: fakeCustomerId, - ProductId: fakeProductId, - bool: fakeBool, - float: fakeFloat, - }); - }); - - it('should set widgets', () => { - const fakeWidgets = [ - { - id: '36141f10-feb6-4c42-acdb-261523729625', - field_name: 'CustomerId', - widget_type: 'Textarea', - widget_params: '', - name: 'Customer', - description: '', - }, - { - id: 'd6a4caa5-68f6-455f-90ff-2ad81856253b', - field_name: 'Price', - widget_type: 'Number', - widget_params: '', - name: '', - description: 'Prices are pointed in USD', - }, - ]; - - component.setWidgets(fakeWidgets); - - expect(component.tableWidgetsList).toEqual(['CustomerId', 'Price']); - expect(component.tableWidgets).toEqual({ - CustomerId: { - id: '36141f10-feb6-4c42-acdb-261523729625', - field_name: 'CustomerId', - widget_type: 'Textarea', - widget_params: null, - name: 'Customer', - description: '', - }, - Price: { - id: 'd6a4caa5-68f6-455f-90ff-2ad81856253b', - field_name: 'Price', - widget_type: 'Number', - widget_params: null, - name: '', - description: 'Prices are pointed in USD', - }, - }); - }); - - it('should return foreign key relations by column name', () => { - component.tableForeignKeys = [ - { - referenced_column_name: 'Id', - referenced_table_name: 'Products', - constraint_name: 'Orders_ibfk_1', - column_name: 'ProductId', - }, - { - referenced_column_name: 'Id', - referenced_table_name: 'Customers', - constraint_name: 'Orders_ibfk_2', - column_name: 'CustomerId', - }, - ]; - - const foreignKeyRelations = component.getRelations('ProductId'); - expect(foreignKeyRelations).toEqual({ - referenced_column_name: 'Id', - referenced_table_name: 'Products', - constraint_name: 'Orders_ibfk_1', - column_name: 'ProductId', - }); - }); - - it('should check if field is readonly', () => { - component.readonlyFields = ['Id', 'Price']; - - const isPriceReafonly = component.isReadonlyField('Price'); - expect(isPriceReafonly).toBe(true); - }); - - it('should check if field is widget', () => { - component.tableWidgetsList = ['CustomerId', 'Price']; - - const isPriceWidget = component.isWidget('Price'); - expect(isPriceWidget).toBe(true); - }); - - describe('updateField for password widget behavior', () => { - beforeEach(() => { - component.tableRowValues = { - id: 1, - username: 'testuser', - password: '***', - }; - }); - - it('should update tableRowValues when password field receives a value', () => { - component.updateField('newPassword', 'password'); - expect(component.tableRowValues.password).toBe('newPassword'); - }); - - it('should update tableRowValues when password field receives empty string', () => { - component.updateField('', 'password'); - expect(component.tableRowValues.password).toBe(''); - }); - - it('should update tableRowValues when password field receives null (clear password)', () => { - component.updateField(null, 'password'); - expect(component.tableRowValues.password).toBe(null); - }); - - it('should handle password field update alongside other fields', () => { - component.updateField('updatedUser', 'username'); - component.updateField('newPassword', 'password'); - - expect(component.tableRowValues.username).toBe('updatedUser'); - expect(component.tableRowValues.password).toBe('newPassword'); - }); - }); - - describe('getFormattedUpdatedRow', () => { - beforeEach(() => { - vi.spyOn(connectionsService, 'currentConnection', 'get').mockReturnValue({ - id: 'test-id', - database: 'test-db', - title: 'Test Connection', - host: 'localhost', - port: '5432', - sid: null, - type: DBtype.Postgres, - username: 'test-user', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: ConnectionType.Direct, - } as Connection); - component.tableTypes = {}; - component.nonModifyingFields = []; - component.pageAction = null; - }); - - describe('onlyTouched filtering', () => { - beforeEach(() => { - component.tableRowValues = { - id: 1, - username: 'original', - email: 'a@x.test', - bio: 'hello', - }; - }); - - it('returns all fields by default', () => { - const result = component.getFormattedUpdatedRow(); - expect(Object.keys(result).sort()).toEqual(['bio', 'email', 'id', 'username']); - }); - - it('returns only touched fields when onlyTouched=true', () => { - component.updateField('renamed', 'username'); - const result = component.getFormattedUpdatedRow(true); - expect(result).toEqual({ username: 'renamed' }); - }); - - it('returns empty object when nothing was touched', () => { - const result = component.getFormattedUpdatedRow(true); - expect(result).toEqual({}); - }); - - it('tracks multiple touched fields', () => { - component.updateField('renamed', 'username'); - component.updateField('b@x.test', 'email'); - const result = component.getFormattedUpdatedRow(true); - expect(result).toEqual({ username: 'renamed', email: 'b@x.test' }); - }); - }); - - it('should include password field when it has a value', () => { - component.tableRowValues = { - id: 1, - username: 'testuser', - password: 'newPassword', - }; - - const result = component.getFormattedUpdatedRow(); - expect((result as any).password).toBe('newPassword'); - }); - - it('should include password field when it is null (explicit clear)', () => { - component.tableRowValues = { - id: 1, - username: 'testuser', - password: null, - }; - - const result = component.getFormattedUpdatedRow(); - expect((result as any).password).toBe(null); - }); - - it('should include password field when it is empty string', () => { - component.tableRowValues = { - id: 1, - username: 'testuser', - password: '', - }; - - const result = component.getFormattedUpdatedRow(); - expect((result as any).password).toBe(''); - }); - - it('should preserve other fields when password is empty', () => { - component.tableRowValues = { - id: 1, - username: 'testuser', - password: '', - }; - - const result = component.getFormattedUpdatedRow(); - expect((result as any).id).toBe(1); - expect((result as any).username).toBe('testuser'); - expect((result as any).password).toBe(''); - }); - }); - - // Integration-style: render the edit form with FK and binary widgets, then save. - // FK widget (foreign-key.component.ts:120) and Binary widget (binary.component.ts:27) - // both re-emit their current value on ngOnInit. Touched-tracking MUST ignore those - // init-time emits — otherwise every edit PUT silently re-writes FK/binary columns - // the user never touched. - describe('render + save: untouched FK and binary fields are not sent', () => { - let tableRowService: TableRowService; - let updateTableRowSpy: ReturnType; - - beforeEach(async () => { - tableRowService = TestBed.inject(TableRowService); - const tablesService = TestBed.inject(TablesService); - - // updateRow's success callback navigates; no routes are registered in this - // harness, so stub navigate to avoid NG04002 unhandled rejections. - vi.spyOn(component.router, 'navigate').mockResolvedValue(true); - - updateTableRowSpy = vi - .spyOn(tableRowService, 'updateTableRow') - .mockReturnValue(of({ row: { id: '42' }, primaryColumns: [{ column_name: 'id' }] } as any)); - - // FK widget loads its current row + autocomplete suggestions via fetchTable. - // Both calls must resolve so its ngOnInit can reach the onFieldChange.emit. - vi.spyOn(tablesService, 'fetchTable').mockReturnValue( - of({ - rows: [{ id: 7, name: 'Alice' }], - primaryColumns: [{ column_name: 'id', data_type: 'int' }], - identity_column: 'name', - } as any), - ); - - vi.spyOn(connectionsService, 'currentConnection', 'get').mockReturnValue({ - id: 'conn-1', - database: 'shop', - title: 'Shop', - host: 'localhost', - port: '5432', - sid: null, - type: DBtype.Postgres, - username: 'u', - ssh: false, - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: ConnectionType.Direct, - } as Connection); - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('conn-1'); - - // Overwrite whatever the initial (failing) ngOnInit left, then render. - component.connectionID = 'conn-1'; - component.tableName = 'orders'; - component.hasKeyAttributesFromURL = true; - component.keyAttributesFromURL = { id: '42' }; - component.keyAttributesListFromStructure = ['id']; - component.readonlyFields = []; - component.nonModifyingFields = []; - component.pageMode = 'edit'; - component.pageAction = null; - component.tableForeignKeys = [ - { - column_name: 'CustomerId', - referenced_column_name: 'id', - referenced_table_name: 'customers', - constraint_name: 'fk_customer', - autocomplete_columns: [], - } as any, - ]; - component.tableRowValues = { - name: 'order-42', - CustomerId: 7, - payload: { type: 'Buffer', data: [1, 2, 3, 4] }, - }; - component.tableTypes = { - name: 'varchar', - CustomerId: 'foreign key', - payload: 'bytea', - }; - component.tableRowRequiredValues = { name: false, CustomerId: false, payload: false }; - component.tableRowStructure = { - name: { column_name: 'name', data_type: 'varchar', allow_null: true }, - CustomerId: { column_name: 'CustomerId', data_type: 'integer', allow_null: true }, - payload: { column_name: 'payload', data_type: 'bytea', allow_null: true }, - } as any; - component.fieldsOrdered = ['name', 'CustomerId', 'payload']; - component.tableWidgetsList = []; - component.tableWidgets = {}; - component.loading = false; - - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - }); - - it('sends an empty body when the user did not touch any field', async () => { - component.handleRowSubmitting(false); - await fixture.whenStable(); - - expect(updateTableRowSpy).toHaveBeenCalledTimes(1); - const body = updateTableRowSpy.mock.calls[0][3]; - expect(body).toEqual({}); - }); - - it('sends only the touched text field when the user edits name', async () => { - // simulate the user typing into the text widget — same path the widget's - // (onFieldChange) output would take through the template - component.updateField('order-43', 'name'); - - component.handleRowSubmitting(false); - await fixture.whenStable(); - - expect(updateTableRowSpy).toHaveBeenCalledTimes(1); - const body = updateTableRowSpy.mock.calls[0][3]; - expect(body).toEqual({ name: 'order-43' }); - expect(body).not.toHaveProperty('CustomerId'); - expect(body).not.toHaveProperty('payload'); - }); - }); -}); diff --git a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts b/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts deleted file mode 100644 index d68c638b3..000000000 --- a/frontend/src/app/components/db-table-row-edit/db-table-row-edit.component.ts +++ /dev/null @@ -1,871 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, NgZone, OnInit, signal } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatListModule } from '@angular/material/list'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import JsonURL from '@jsonurl/jsonurl'; -import JSON5 from 'json5'; -import { isEqual } from 'lodash-es'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import { defaultTimestampValues, recordEditTypes, timestampTypes, UIwidgets } from 'src/app/consts/record-edit-types'; -import { formatFieldValue } from 'src/app/lib/format-field-value'; -import { normalizeFieldName, normalizeTableName } from 'src/app/lib/normalize'; -import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; -import { Alert, AlertType, ServerError } from 'src/app/models/alert'; -import { DBtype } from 'src/app/models/connection'; -import { CustomAction, CustomEvent, TableField, TableForeignKey, Widget } from 'src/app/models/table'; -import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { TableRowService } from 'src/app/services/table-row.service'; -import { TableStateService } from 'src/app/services/table-state.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { DbActionLinkDialogComponent } from '../dashboard/db-table-view/db-action-link-dialog/db-action-link-dialog.component'; -import { BbBulkActionConfirmationDialogComponent } from '../dashboard/db-table-view/db-bulk-action-confirmation-dialog/db-bulk-action-confirmation-dialog.component'; -import { DbTableRowViewComponent } from '../dashboard/db-table-view/db-table-row-view/db-table-row-view.component'; -import { PlaceholderRowEditComponent } from '../skeletons/placeholder-row-edit/placeholder-row-edit.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { BannerComponent } from '../ui-components/banner/banner.component'; -import { BreadcrumbsComponent } from '../ui-components/breadcrumbs/breadcrumbs.component'; - -@Component({ - selector: 'app-db-table-row-edit', - templateUrl: './db-table-row-edit.component.html', - styleUrls: ['./db-table-row-edit.component.css'], - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - MatButtonModule, - MatDialogModule, - MatIconModule, - MatInputModule, - MatSelectModule, - MatTooltipModule, - MatListModule, - MatProgressSpinnerModule, - RouterModule, - MatExpansionModule, - MatChipsModule, - DynamicModule, - SignalComponentIoModule, - AlertComponent, - PlaceholderRowEditComponent, - BannerComponent, - BreadcrumbsComponent, - DbTableRowViewComponent, - ], -}) -export class DbTableRowEditComponent implements OnInit { - public loading: boolean = true; - public connectionID: string | null = null; - public connectionName: string | null = null; - public tableName: string | null = null; - public dispalyTableName: string | null = null; - public tableRowValues: Record; - private touchedFields = new Set(); - public tableRowStructure: object; - public tableRowRequiredValues: object; - public identityColumn: string; - public readonlyFields: string[]; - public nonModifyingFields: string[]; - public keyAttributesFromURL: object = {}; - public hasKeyAttributesFromURL: boolean; - public dubURLParams: object; - public keyAttributesListFromStructure: string[] = []; - public isPrimaryKeyUpdated: boolean; - public tableTypes: object; - public tableWidgets: object; - public tableWidgetsList: string[] = []; - public shownRows; - public submitting = false; - public UIwidgets = UIwidgets; - public isServerError: boolean = false; - public serverError: ServerError; - public fieldsOrdered: string[]; - public rowActions: CustomAction[]; - public referencedTables: any = []; - public referencedRecords: {} = {}; - public referencedTablesURLParams: any; - public isDesktop: boolean = true; - public canDelete: boolean; - public pageAction: string; - public pageMode: string = null; - public tableFiltersUrlString: string; - public backUrlParams: object; - - public tableForeignKeys: TableForeignKey[]; - - public selectedRow: any; - public relatedRecordsProperties: {}; - - public isTestConnectionWarning: Alert = { - id: 10000000, - type: AlertType.Error, - message: 'This is a TEST DATABASE, public to all. Avoid entering sensitive data!', - }; - private confirmationDialogRef: any; - - originalOrder = () => { - return 0; - }; - routeSub: any; - - private _permissions = inject(CedarPermissionService); - private _tableResourceId = signal(''); - - protected canEditRow = this._tablePermission('table:edit'); - protected canAddRow = this._tablePermission('table:add'); - protected canDeleteRow = this._tablePermission('table:delete'); - - constructor( - private _connections: ConnectionsService, - private _tables: TablesService, - private _tableRow: TableRowService, - private _notifications: NotificationsService, - private _tableState: TableStateService, - private route: ActivatedRoute, - private ngZone: NgZone, - public router: Router, - public dialog: MatDialog, - private title: Title, - ) {} - - get isTestConnection() { - return this._connections.currentConnection.isTestConnection; - } - - get connectionType() { - return this._connections.currentConnection.type; - } - - ngOnInit(): void { - this.loading = true; - this.connectionID = this._connections.currentConnectionID; - this.tableFiltersUrlString = JsonURL.stringify(this._tableState.getBackUrlFilters()); - const navUrlParams = this._tableState.getBackUrlParams(); - this.backUrlParams = { - ...navUrlParams, - ...(this.tableFiltersUrlString !== 'null' ? { filters: this.tableFiltersUrlString } : {}), - }; - - this._tableState.cast.subscribe((row) => { - this.selectedRow = row; - }); - - this.routeSub = this.route.queryParams.subscribe((params) => { - this.tableName = this.route.snapshot.paramMap.get('table-name'); - this._tableResourceId.set(`${this.connectionID}/${this.tableName}`); - if (Object.keys(params).length === 0) { - this._tables.fetchTableStructure(this.connectionID, this.tableName).subscribe((res) => { - this.dispalyTableName = res.display_name || normalizeTableName(this.tableName); - this.title.setTitle(`${this.dispalyTableName} - Add new record | Rocketadmin`); - - this.keyAttributesListFromStructure = res.primaryColumns.map((field: TableField) => field.column_name); - this.readonlyFields = res.readonly_fields; - this.tableForeignKeys = res.foreignKeys; - this.setRowStructure(res.structure); - res.table_widgets && this.setWidgets(res.table_widgets); - this.shownRows = this.getModifyingFields(res.structure).filter( - (field: TableField) => !res.excluded_fields.includes(field.column_name), - ); - const allowNullFields = res.structure - .filter((field: TableField) => field.allow_null) - .map((field: TableField) => field.column_name); - this.tableRowValues = Object.assign( - {}, - ...this.shownRows.map((field: TableField) => { - if (allowNullFields.includes(field.column_name)) { - return { [field.column_name]: null }; - // } else if (this.tableTypes[field.column_name] === 'boolean') { - // return { [field.column_name]: false } - } - return { [field.column_name]: '' }; - }), - ); - const shownFieldsList = this.shownRows.map((field: TableField) => field.column_name); - if (res.columns_view && res.columns_view.length) { - this.fieldsOrdered = [...res.columns_view].filter((field) => shownFieldsList.includes(field)); - } else if (res.list_fields.length) { - this.fieldsOrdered = [...res.list_fields].filter((field) => shownFieldsList.includes(field)); - } else { - this.fieldsOrdered = Object.keys(this.tableRowValues).map((key) => key); - } - this.loading = false; - }); - } else { - const { action, mode, ...primaryKeys } = params; - if (action) { - this.pageAction = action; - } - - if (mode) { - this.pageMode = mode; - } - - this.keyAttributesFromURL = primaryKeys; - this.dubURLParams = { ...primaryKeys, action: 'dub' }; - this.hasKeyAttributesFromURL = !!Object.keys(this.keyAttributesFromURL).length; - this._tableRow.fetchTableRow(this.connectionID, this.tableName, params).subscribe( - (res) => { - this.dispalyTableName = res.display_name || normalizeTableName(this.tableName); - this.title.setTitle(`${this.dispalyTableName} - Edit record | Rocketadmin`); - this.canDelete = res.can_delete; - this.keyAttributesListFromStructure = res.primaryColumns.map((field: TableField) => field.column_name); - - this.nonModifyingFields = res.structure - .filter( - (field: TableField) => - !this.getModifyingFields(res.structure).some( - (modifyingField) => field.column_name === modifyingField.column_name, - ), - ) - .map((field: TableField) => field.column_name); - this.readonlyFields = [...res.readonly_fields, ...this.nonModifyingFields]; - if (this.connectionType === DBtype.Dynamo || this.connectionType === DBtype.ClickHouse) { - this.readonlyFields = [ - ...this.readonlyFields, - ...res.primaryColumns.map((field: TableField) => field.column_name), - ]; - } - this.tableForeignKeys = res.foreignKeys; - // this.shownRows = res.structure.filter((field: TableField) => !field.column_default?.startsWith('nextval')); - this.tableRowValues = { ...res.row }; - if (res.columns_view && res.columns_view.length) { - this.fieldsOrdered = [...res.columns_view]; - } else if (res.list_fields.length) { - this.fieldsOrdered = [...res.list_fields]; - } else { - this.fieldsOrdered = Object.keys(this.tableRowValues).map((key) => key); - } - - if (this.pageAction === 'dub') { - this.fieldsOrdered = this.fieldsOrdered.filter((field) => !this.nonModifyingFields.includes(field)); - } - - if (res.table_actions) this.rowActions = res.table_actions; - res.table_widgets && this.setWidgets(res.table_widgets); - this.setRowStructure(res.structure); - this.identityColumn = res.identity_column; - - if ( - res.referenced_table_names_and_columns && - res.referenced_table_names_and_columns.length > 0 && - res.referenced_table_names_and_columns[0].referenced_by[0] !== null - ) { - this.isDesktop = window.innerWidth >= 1280; - - this.referencedTables = res.referenced_table_names_and_columns[0].referenced_by.map((table: any) => { - return { ...table, displayTableName: table.display_name || normalizeTableName(table.table_name) }; - }); - - this.referencedTablesURLParams = res.referenced_table_names_and_columns[0].referenced_by.map( - (table: any) => { - const params = { - [table.column_name]: { - eq: this.tableRowValues[res.referenced_table_names_and_columns[0].referenced_on_column_name], - }, - }; - return { - filters: JsonURL.stringify(params), - page_index: 0, - }; - }, - ); - - res.referenced_table_names_and_columns[0].referenced_by.forEach((table: any) => { - const filters = { - [table.column_name]: { - eq: this.tableRowValues[res.referenced_table_names_and_columns[0].referenced_on_column_name], - }, - }; - - this._tables - .fetchTable({ - connectionID: this.connectionID, - tableName: table.table_name, - requstedPage: 1, - chunkSize: 30, - filters, - }) - .subscribe((res) => { - const foreignKeysList = res.foreignKeys.map( - (foreignKey: TableForeignKey) => foreignKey.column_name, - ); - this.relatedRecordsProperties = Object.assign({}, this.relatedRecordsProperties, { - [table.table_name]: { - connectionID: this.connectionID, - tableName: table.table_name, - columnsOrder: res.list_fields, - primaryColumns: res.primaryColumns, - foreignKeys: Object.assign( - {}, - ...res.foreignKeys.map((foreignKey: TableForeignKey) => ({ - [foreignKey.column_name]: foreignKey, - })), - ), - foreignKeysList, - widgets: Object.assign( - {}, - ...res.widgets.map((widget: Widget) => { - let parsedParams; - - try { - parsedParams = JSON5.parse(widget.widget_params); - } catch { - parsedParams = {}; - } - - return { - [widget.field_name]: { - ...widget, - widget_params: parsedParams, - }, - }; - }), - ), - widgetsList: res.widgets.map((widget) => widget.field_name), - fieldsTypes: getTableTypes(res.structure, foreignKeysList), - relatedRecords: [], - link: `/dashboard/${this.connectionID}/${table.table_name}/entry`, - }, - }); - - if (res.rows?.length) { - const firstRow = res.rows[0]; - - const relatedTableForeignKeys = Object.assign( - {}, - ...res.foreignKeys.map((foreignKey: TableForeignKey) => ({ - [foreignKey.column_name]: foreignKey, - })), - ); - - this._tableRow - .fetchTableRow( - this.connectionID, - table.table_name, - res.primaryColumns.reduce((keys, column) => { - if ( - res.foreignKeys.map((foreignKey) => foreignKey.column_name).includes(column.column_name) - ) { - const referencedColumnNameOfForeignKey = - relatedTableForeignKeys[column.column_name]?.referenced_column_name; - keys[column.column_name] = firstRow[column.column_name][referencedColumnNameOfForeignKey]; - } else { - keys[column.column_name] = firstRow[column.column_name]; - } - return keys; - }, {}), - ) - .subscribe((res) => { - this.relatedRecordsProperties[table.table_name].relatedRecords = - res.referenced_table_names_and_columns[0]; - }); - } - - let identityColumn = res.identity_column; - let fieldsOrder = []; - - const foreignKeyMap = {}; - for (const fk of res.foreignKeys) { - foreignKeyMap[fk.column_name] = fk.referenced_column_name; - } - - // Format each row - const formattedRows = res.rows.map((row) => { - const formattedRow = {}; - - for (const key in row) { - if (foreignKeyMap[key] && typeof row[key] === 'object' && row[key] !== null) { - const preferredKey = Object.keys(row[key]).find((k) => k !== foreignKeyMap[key]); - formattedRow[key] = preferredKey ? row[key][preferredKey] : row[key][foreignKeyMap[key]]; - } else { - formattedRow[key] = formatFieldValue( - row[key], - res.structure.find((field: TableField) => field.column_name === key)?.data_type || 'text', - ); - } - } - return formattedRow; - }); - - if (res.identity_column && res.list_fields.length) { - identityColumn = { - isSet: true, - name: res.identity_column, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[res.identity_column]?.name || - normalizeFieldName(res.identity_column), - }; - fieldsOrder = res.list_fields - .filter((field: string) => field !== res.identity_column) - .slice(0, 3) - .map((field: string) => { - return { - fieldName: field, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[field]?.name || - normalizeFieldName(field), - }; - }); - } - - if (res.identity_column && !res.list_fields.length) { - identityColumn = { - isSet: true, - name: res.identity_column, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[res.identity_column]?.name || - normalizeFieldName(res.identity_column), - }; - fieldsOrder = res.structure - .filter((field: TableField) => field.column_name !== res.identity_column) - .slice(0, 3) - .map((field: TableField) => { - return { - fieldName: field.column_name, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[field.column_name]?.name || - normalizeFieldName(field.column_name), - }; - }); - } - - if (!res.identity_column && res.list_fields.length) { - identityColumn = { - isSet: false, - name: res.list_fields[0], - displayName: - this.relatedRecordsProperties[table.table_name].widgets[res.list_fields[0]]?.name || - normalizeFieldName(res.list_fields[0]), - }; - fieldsOrder = res.list_fields.slice(1, 4).map((field: string) => { - return { - fieldName: field, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[field]?.name || - normalizeFieldName(field), - }; - }); - } - - if (!res.identity_column && !res.list_fields.length) { - identityColumn = { - isSet: false, - name: res.structure[0].column_name, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[res.structure[0].column_name]?.name || - normalizeFieldName(res.structure[0].column_name), - }; - fieldsOrder = res.structure.slice(1, 4).map((field: TableField) => { - return { - fieldName: field.column_name, - displayName: - this.relatedRecordsProperties[table.table_name].widgets[field.column_name]?.name || - normalizeFieldName(field.column_name), - }; - }); - } - - const tableRecords = { - rawRows: res.rows, - formattedRows, - identityColumn, - fieldsOrder, - foreignKeys: res.foreignKeys, - }; - this.referencedRecords[table.table_name] = tableRecords; - }); - }); - } - - this.loading = false; - }, - (err) => { - this.loading = false; - this.isServerError = true; - this.serverError = { abstract: err.error?.message || err.message, details: err.error?.originalMessage }; - console.log(err); - }, - ); - } - }); - } - - get inputs() { - return recordEditTypes[this.connectionType]; - } - - get currentConnection() { - return this._connections.currentConnection; - } - - get pageTitle(): string { - if (this.pageMode === 'view') return 'View row'; - if (this.hasKeyAttributesFromURL && this.pageAction === 'dub') return 'Duplicate row'; - if (this.hasKeyAttributesFromURL && !this.pageAction) return 'Edit row'; - return 'Add row'; - } - - getCrumbs(name: string) { - let pageTitle = ''; - - if (this.hasKeyAttributesFromURL && this.pageAction === 'dub') { - pageTitle = 'Duplicate row'; - } - - if (this.hasKeyAttributesFromURL && !this.pageAction) { - pageTitle = 'Edit row'; - } - - if (!this.hasKeyAttributesFromURL) { - pageTitle = 'Add row'; - } - - return [ - { - label: name, - link: `/dashboard/${this.connectionID}`, - }, - { - label: this.dispalyTableName, - link: `/dashboard/${this.connectionID}/${this.tableName}`, - queryParams: this.backUrlParams, - }, - { - label: pageTitle, - link: null, - }, - ]; - } - - setRowStructure(structure: TableField[]) { - this.tableRowStructure = Object.assign( - {}, - ...structure.map((field: TableField) => { - return { [field.column_name]: field }; - }), - ); - - const foreignKeysList = this.tableForeignKeys.map((field: TableForeignKey) => { - return field.column_name; - }); - this.tableTypes = getTableTypes(structure, foreignKeysList); - - this.tableRowRequiredValues = Object.assign( - {}, - ...structure.map((field: TableField) => { - return { [field.column_name]: field.allow_null === false && field.column_default === null }; - }), - ); - } - - setWidgets(widgets: Widget[]) { - this.tableWidgetsList = widgets.map((widget: Widget) => widget.field_name); - this.tableWidgets = Object.assign( - {}, - ...widgets.map((widget: Widget) => { - let params = null; - if (widget.widget_params) { - try { - params = JSON5.parse(widget.widget_params); - } catch { - params = null; - } - } - return { - [widget.field_name]: { ...widget, widget_params: params }, - }; - }), - ); - } - - getRelations = (columnName: string) => { - const relation = this.tableForeignKeys.find((relation) => relation.column_name === columnName); - return relation; - }; - - isReadonlyField(columnName: string) { - return this.readonlyFields.includes(columnName); - } - - isWidget(columnName: string) { - return this.tableWidgetsList.includes(columnName); - } - - getModifyingFields(fields) { - return fields.filter( - (field: TableField) => - !field.auto_increment && - !(this.keyAttributesListFromStructure.includes(field.column_name) && field.column_default) && - !( - timestampTypes.includes(field.data_type) && - field.column_default && - defaultTimestampValues[this.connectionType].includes(field.column_default.toLowerCase().replace(/\(.*\)/, '')) - ), - ); - } - - updateField = (updatedValue: any, field: string) => { - const existing = this.tableRowValues[field]; - if (!isEqual(updatedValue, existing)) { - this.touchedFields.add(field); - } - if ( - typeof updatedValue === 'object' && - updatedValue !== null && - typeof existing === 'object' && - existing !== null - ) { - for (const prop of Object.getOwnPropertyNames(existing)) { - delete existing[prop]; - } - Object.assign(existing, updatedValue); - } else { - this.tableRowValues[field] = updatedValue; - } - - if (this.keyAttributesFromURL && Object.keys(this.keyAttributesFromURL).includes(field)) { - this.isPrimaryKeyUpdated = true; - } - }; - - getFormattedUpdatedRow = (onlyTouched: boolean = false) => { - let updatedRow = onlyTouched - ? Object.fromEntries(Object.entries(this.tableRowValues).filter(([key]) => this.touchedFields.has(key))) - : { ...this.tableRowValues }; - - //crutch, format datetime fields - //if no one edit manually datetime field, we have to remove '.000Z', cuz mysql return this format but it doesn't record it - - if (this.connectionType === DBtype.MySQL) { - const datetimeFields = Object.entries(this.tableTypes).filter( - ([_key, value]) => value === 'datetime' || value === 'timestamp', - ); - if (datetimeFields.length) { - for (const datetimeField of datetimeFields) { - if (updatedRow[datetimeField[0]]) { - updatedRow[datetimeField[0]] = updatedRow[datetimeField[0]] - .replace('T', ' ') - .replace('Z', '') - .split('.')[0]; - } - } - } - - const dateFields = Object.entries(this.tableTypes).filter(([_key, value]) => value === 'date'); - if (dateFields.length) { - for (const dateField of dateFields) { - if (updatedRow[dateField[0]]) { - updatedRow[dateField[0]] = updatedRow[dateField[0]].split('T')[0]; - } - } - } - } - //end crutch - - // don't ovverride primary key fields for dynamoDB - if (this.connectionType === DBtype.Dynamo || this.connectionType === DBtype.ClickHouse) { - const primaryKeyFields = Object.keys(this.keyAttributesFromURL); - primaryKeyFields.forEach((field) => { - delete updatedRow[field]; - }); - } - - //parse json fields - const jsonFields = Object.entries(this.tableTypes) - .filter( - ([_key, value]) => - value === 'json' || - value === 'jsonb' || - value === 'array' || - value === 'ARRAY' || - value === 'object' || - value === 'set' || - value === 'list' || - value === 'map', - ) - .map((jsonField) => jsonField[0]); - if (jsonFields.length) { - for (const jsonField of jsonFields) { - if (updatedRow[jsonField] === '') updatedRow[jsonField] = null; - if (typeof updatedRow[jsonField] === 'string') { - console.log(updatedRow[jsonField].toString()); - const updatedFiled = JSON.parse(updatedRow[jsonField].toString()); - updatedRow[jsonField] = updatedFiled; - } - } - } - - if (this.pageAction === 'dub') { - this.nonModifyingFields.forEach((field) => { - delete updatedRow[field]; - }); - } - - return updatedRow; - }; - - handleRowSubmitting(continueEditing: boolean) { - if (this.hasKeyAttributesFromURL && this.pageAction !== 'dub') { - this.updateRow(continueEditing); - } else { - this.addRow(continueEditing); - } - } - - addRow(continueEditing: boolean) { - this.submitting = true; - - const formattedUpdatedRow = this.getFormattedUpdatedRow(); - - this._tableRow.addTableRow(this.connectionID, this.tableName, formattedUpdatedRow).subscribe( - (res) => { - this.keyAttributesFromURL = {}; - for (let i = 0; i < res.primaryColumns.length; i++) { - this.keyAttributesFromURL[res.primaryColumns[i].column_name] = res.row[res.primaryColumns[i].column_name]; - } - this.ngZone.run(() => { - if (continueEditing) { - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}/entry`], { - queryParams: this.keyAttributesFromURL, - }); - } else { - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}`], { - queryParams: { - filters: this.tableFiltersUrlString, - page_index: 0, - }, - }); - } - }); - - this.pageAction = null; - - this._notifications.dismissAlert(); - this.submitting = false; - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - updateRow(continueEditing: boolean) { - this.submitting = true; - - const formattedUpdatedRow = this.getFormattedUpdatedRow(true); - - this._tableRow - .updateTableRow(this.connectionID, this.tableName, this.keyAttributesFromURL, formattedUpdatedRow) - .subscribe( - (res) => { - this.touchedFields.clear(); - this.ngZone.run(() => { - if (continueEditing) { - if (this.isPrimaryKeyUpdated) { - this.ngZone.run(() => { - let params = {}; - Object.keys(this.keyAttributesFromURL).forEach((key) => { - params[key] = res.row[key]; - }); - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}/entry`], { - queryParams: params, - }); - }); - } - this._notifications.dismissAlert(); - } else { - this._notifications.dismissAlert(); - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}`], { - queryParams: this.backUrlParams, - }); - } - }); - }, - () => { - this.submitting = false; - }, - () => { - this.submitting = false; - }, - ); - } - - handleDeleteRow() { - this.handleActivateAction({ - title: 'Delete row', - require_confirmation: true, - } as CustomEvent); - } - - handleActivateAction(action: CustomEvent) { - if (action.require_confirmation) { - this.confirmationDialogRef = this.dialog.open(BbBulkActionConfirmationDialogComponent, { - width: '25em', - data: { id: action.id, title: action.title, primaryKeys: [this.keyAttributesFromURL] }, - }); - - if (!action.id) { - this.confirmationDialogRef.afterClosed().subscribe((_res) => { - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}`], { - queryParams: this.backUrlParams, - }); - }); - } - } else { - this._tables - .activateActions(this.connectionID, this.tableName, action.id, action.title, [this.keyAttributesFromURL]) - .subscribe((res) => { - if (res?.location) - this.dialog.open(DbActionLinkDialogComponent, { - width: '25em', - data: { href: res.location, actionName: action.title, primaryKeys: this.keyAttributesFromURL }, - }); - }); - } - } - - handleViewRow(tableName: string, row: {}) { - // this.selectedRowType = 'record'; - this._tableState.selectRow({ - ...this.relatedRecordsProperties[tableName], - record: row, - primaryKeys: this.relatedRecordsProperties[tableName].primaryColumns.reduce((acc, column) => { - acc[column.column_name] = row[column.column_name]; - return acc; - }, {}), - }); - } - - switchToEditMode() { - this.pageMode = null; - this.router.navigate([`/dashboard/${this.connectionID}/${this.tableName}/entry`], { - queryParams: { - ...this.keyAttributesFromURL, - }, - }); - } - - private _tablePermission(action: string) { - return computed(() => { - const rid = this._tableResourceId(); - return rid ? this._permissions.canI(action, 'Table', rid)() : null; - }); - } -} diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css deleted file mode 100644 index 400f91490..000000000 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.css +++ /dev/null @@ -1,915 +0,0 @@ -:host { - display: flex; - height: calc(100vh - 44px); - width: 100%; -} - -.schema-editor { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - overflow: hidden; - position: relative; -} - -.schema-editor--routed { - height: calc(100vh - var(--mat-toolbar-standard-height)); -} - -/* ── Header (routed page) ── */ - -.schema-editor__header { - display: flex; - align-items: center; - gap: 8px; - padding: var(--top-margin) 16px 12px; - flex-shrink: 0; - width: 100%; -} - -.schema-editor__back-button { - flex-shrink: 0; -} - -.schema-editor__title { - font-size: 20px; - font-weight: 600; - margin: 0 !important; -} - -/* ── Close button (embedded panel) ── */ - -.schema-editor__close-button { - position: absolute; - top: 8px; - right: 8px; - z-index: 10; -} - -/* ── Body (two-column) ── */ - -.schema-editor__body { - flex: 1; - display: flex; - min-height: 0; - overflow: hidden; - gap: 16px; - padding: 0 16px 16px; -} - -/* ── Canvas (left) ── */ - -.schema-editor__canvas { - flex: 1; - display: flex; - min-width: 0; -} - -.schema-editor__diagram { - flex: 1; - display: flex; - min-width: 0; -} - -.schema-editor__diagram-loading { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -/* Welcome */ - -.schema-editor__welcome { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 24px; - max-width: 520px; - margin: 0 auto; - overflow-y: auto; -} - -.schema-editor__welcome-icon-box { - width: 48px; - height: 48px; - border-radius: 12px; - background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 4px; -} - -.schema-editor__welcome-icon { - color: var(--color-accentedPalette-500); - font-size: 26px !important; - width: 26px !important; - height: 26px !important; - transform: rotate(-90deg); -} - -.schema-editor__welcome-title { - font-size: 16px; - font-weight: 500; - color: rgba(0, 0, 0, 0.7); - margin: 0; - text-align: center; -} - -.schema-editor__welcome-heading { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.schema-editor__welcome-subtitle { - font-size: 14px; - color: rgba(0, 0, 0, 0.5); - margin: 0; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__welcome-title { - color: rgba(255, 255, 255, 0.7); - } - .schema-editor__welcome-subtitle { - color: rgba(255, 255, 255, 0.5); - } -} - -.schema-editor__suggestions-block { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - margin-top: 8px; -} - -.schema-editor__suggestions-heading { - font-size: 12px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.6px; - color: rgba(0, 0, 0, 0.42); - margin: 0; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__suggestions-heading { - color: rgba(255, 255, 255, 0.45); - } -} - -.schema-editor__suggestions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - margin-top: 0; -} - -.schema-editor__templates { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - width: 100%; - max-width: 460px; -} - -.template-card { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px 10px; - text-align: left; - background: var(--mat-sidenav-content-background-color); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 8px; - cursor: pointer; - transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; -} - -.template-card:hover { - border-color: var(--color-accentedPalette-500); - box-shadow: 0 1px 0 0 color-mix(in srgb, var(--color-accentedPalette-500), transparent 80%); -} - -@media (prefers-color-scheme: dark) { - .template-card { - border-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.9); - } -} - -.template-card__head { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.template-card__icon-box { - flex-shrink: 0; - width: 20px; - height: 20px; - border-radius: 5px; - display: flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--color-accentedPalette-500), transparent 88%); -} - -.template-card__icon { - font-size: 13px !important; - width: 13px !important; - height: 13px !important; - color: var(--color-accentedPalette-500); -} - -.template-card__title { - flex: 1; - font-size: 12.5px; - font-weight: 600; - color: rgba(0, 0, 0, 0.88); - line-height: 1.25; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -@media (prefers-color-scheme: dark) { - .template-card__title { - color: rgba(255, 255, 255, 0.92); - } -} - -.template-card__tables { - font-size: 11px; - color: rgba(0, 0, 0, 0.5); - line-height: 1.35; -} - -@media (prefers-color-scheme: dark) { - .template-card__tables { - color: rgba(255, 255, 255, 0.5); - } -} - -.suggestion-chip { - display: inline-flex; - align-items: center; - gap: 6px; - background-color: var(--mat-sidenav-content-background-color); - border: 1px solid #d3d3d3; - border-radius: 16px; - padding: 6px 14px 6px 10px; - font-size: 13px; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.suggestion-chip__icon { - font-size: 16px; - width: 16px; - height: 16px; - color: var(--color-primaryPalette-500); - flex-shrink: 0; -} - -.suggestion-chip:hover { - background-color: var(--color-primaryPalette-50); -} - -@media (prefers-color-scheme: dark) { - .suggestion-chip { - border-color: #4a4a4a; - color: rgba(255, 255, 255, 0.9); - } - .suggestion-chip:hover { - background-color: rgba(255, 255, 255, 0.08); - } -} - -/* ── Chat panel (right) ── */ - -.schema-editor__panel { - width: 400px; - flex-shrink: 0; - display: flex; - flex-direction: column; - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 12px; - background: transparent; - min-height: 0; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__panel { - border-color: rgba(255, 255, 255, 0.1); - } -} - -.schema-editor__panel-header { - display: flex; - align-items: center; - gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - font-weight: 600; - font-size: 14px; - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__panel-header { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.schema-editor__panel-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #6384ff; -} - -.schema-editor__panel-title { - font-size: 14px; - font-weight: 600; -} - -/* Messages */ - -.schema-editor__messages { - flex: 1; - overflow-y: auto; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; - min-height: 0; -} - -.schema-editor__messages-empty { - font-size: 14px; - color: rgba(0, 0, 0, 0.6); - margin: 40px 0 0; - line-height: 1.45; -} - -.schema-editor__try-asking-block { - margin-top: auto; - display: flex; - flex-direction: column; - gap: 8px; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__messages-empty { - color: rgba(255, 255, 255, 0.5); - } -} - -.schema-editor__try-asking-heading { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.7px; - color: rgba(0, 0, 0, 0.42); - margin: 4px 0 0; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__try-asking-heading { - color: rgba(255, 255, 255, 0.45); - } -} - -.schema-editor__try-asking { - display: flex; - flex-direction: column; - gap: 6px; -} - -.try-asking-item { - display: flex; - align-items: flex-start; - gap: 8px; - background: none; - border: 1px dashed rgba(0, 0, 0, 0.14); - border-radius: 10px; - padding: 8px 10px; - text-align: left; - font-size: 12.5px; - line-height: 1.4; - color: rgba(0, 0, 0, 0.7); - cursor: pointer; - transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; -} - -.try-asking-item:hover { - border-color: var(--color-accentedPalette-500); - background-color: color-mix(in srgb, var(--color-accentedPalette-500), transparent 94%); - color: rgba(0, 0, 0, 0.9); -} - -@media (prefers-color-scheme: dark) { - .try-asking-item { - border-color: rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.7); - } - .try-asking-item:hover { - color: rgba(255, 255, 255, 0.95); - } -} - -.try-asking-item__icon { - font-size: 14px !important; - width: 14px !important; - height: 14px !important; - margin-top: 1px; - color: var(--color-accentedPalette-500); - flex-shrink: 0; -} - -.schema-editor__message { - border-radius: 8px; - padding: 10px 14px; - max-width: 95%; - line-height: 1.5; - white-space: pre-wrap; - font-size: 13px; -} - -.schema-editor__message--user { - align-self: flex-end; - background-color: #f0f4f8; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__message--user { - background-color: #2d3748; - } -} - -.schema-editor__message--ai { - align-self: stretch; - background-color: transparent; - padding-left: 0; - padding-right: 0; - max-width: 100%; -} - -.schema-editor__message-text { - white-space: pre-wrap; -} - -.schema-editor__message--error { - align-self: flex-start; - display: flex; - align-items: flex-start; - gap: 8px; - max-width: 100%; -} - -@media (prefers-color-scheme: light) { - .schema-editor__message--error { - background-color: var(--color-warnPalette-100); - color: var(--color-warnPalette-100-contrast); - } -} - -@media (prefers-color-scheme: dark) { - .schema-editor__message--error { - background-color: var(--color-warnDarkPalette-200); - color: var(--color-warnDarkPalette-200-contrast); - } -} - -.schema-editor__error-icon { - font-size: 18px; - height: 18px; - width: 18px; - flex-shrink: 0; - margin-top: 2px; -} - -/* Changes */ - -.schema-editor__changes { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 10px; -} - -/* ── Structured change card ── */ - -.change-card { - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 8px; - padding: 10px 12px; - background: #fff; - display: flex; - flex-direction: column; - gap: 8px; -} - -@media (prefers-color-scheme: dark) { - .change-card { - border-color: rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.04); - } -} - -.change-card__head { - display: flex; - align-items: center; - gap: 10px; -} - -.change-card__action-icon { - flex-shrink: 0; - width: 28px; - height: 28px; - border-radius: 7px; - display: flex; - align-items: center; - justify-content: center; -} - -.change-card__action-icon mat-icon { - font-size: 16px !important; - width: 16px !important; - height: 16px !important; -} - -.change-card[data-tone="add"] .change-card__action-icon { - background: color-mix(in srgb, #16a34a 14%, transparent); - color: #15803d; -} - -.change-card[data-tone="edit"] .change-card__action-icon { - background: color-mix(in srgb, var(--color-accentedPalette-500) 14%, transparent); - color: var(--color-accentedPalette-500); -} - -.change-card[data-tone="remove"] .change-card__action-icon { - background: color-mix(in srgb, #dc2626 14%, transparent); - color: #dc2626; -} - -@media (prefers-color-scheme: dark) { - .change-card[data-tone="add"] .change-card__action-icon { color: #4ade80; } - .change-card[data-tone="remove"] .change-card__action-icon { color: #f87171; } -} - -.change-card__head-text { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - -.change-card__action { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.4px; - color: rgba(0, 0, 0, 0.45); -} - -@media (prefers-color-scheme: dark) { - .change-card__action { - color: rgba(255, 255, 255, 0.5); - } -} - -.change-card__target { - font-size: 14px; - font-weight: 600; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - color: rgba(0, 0, 0, 0.88); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -@media (prefers-color-scheme: dark) { - .change-card__target { - color: rgba(255, 255, 255, 0.92); - } -} - -.change-card__summary { - margin: 0; - font-size: 12.5px; - color: rgba(0, 0, 0, 0.62); - line-height: 1.45; -} - -@media (prefers-color-scheme: dark) { - .change-card__summary { - color: rgba(255, 255, 255, 0.62); - } -} - -.change-card__columns { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: max-content minmax(0, 1fr) auto; - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 6px; - overflow: hidden; - font-size: 12px; -} - -@media (prefers-color-scheme: dark) { - .change-card__columns { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.change-card__column { - display: contents; -} - -.change-card__column > * { - display: flex; - align-items: center; - padding: 6px 10px; - border-top: 1px solid rgba(0, 0, 0, 0.04); -} - -.change-card__column:first-child > * { - border-top: none; -} - -@media (prefers-color-scheme: dark) { - .change-card__column > * { - border-top-color: rgba(255, 255, 255, 0.06); - } -} - -.change-card__column-name { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-weight: 500; - color: rgba(0, 0, 0, 0.85); -} - -@media (prefers-color-scheme: dark) { - .change-card__column-name { - color: rgba(255, 255, 255, 0.88); - } -} - -.change-card__column-type { - color: rgba(0, 0, 0, 0.5); - font-size: 11.5px; - padding-left: 0 !important; -} - -@media (prefers-color-scheme: dark) { - .change-card__column-type { - color: rgba(255, 255, 255, 0.55); - } -} - -.change-card__column-badges { - flex-wrap: wrap; - gap: 4px; - justify-content: flex-end; -} - -.change-card__badge { - font-size: 9px; - font-weight: 700; - letter-spacing: 0.5px; - padding: 2px 6px; - border-radius: 3px; - line-height: 1.2; - text-transform: uppercase; -} - -.change-card__badge--pk { - background: color-mix(in srgb, #f59e0b 18%, transparent); - color: #b45309; -} - -.change-card__badge--fk { - background: color-mix(in srgb, var(--color-accentedPalette-500) 18%, transparent); - color: var(--color-accentedPalette-500); -} - -.change-card__badge--nn, -.change-card__badge--uq, -.change-card__badge--def { - background: rgba(0, 0, 0, 0.06); - color: rgba(0, 0, 0, 0.6); -} - -@media (prefers-color-scheme: dark) { - .change-card__badge--pk { color: #fbbf24; } - .change-card__badge--nn, - .change-card__badge--uq, - .change-card__badge--def { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.6); - } -} - -.change-card__sql { - margin: 0; - font-size: 11px; -} - -.change-card__sql summary { - cursor: pointer; - user-select: none; - font-size: 11px; - font-weight: 500; - color: rgba(0, 0, 0, 0.5); - padding: 2px 0; -} - -.change-card__sql summary:hover { - color: var(--color-accentedPalette-500); -} - -@media (prefers-color-scheme: dark) { - .change-card__sql summary { - color: rgba(255, 255, 255, 0.5); - } -} - -.change-card__sql pre { - background: rgba(0, 0, 0, 0.04); - border-radius: 4px; - padding: 8px 10px; - font-size: 11px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - overflow-x: auto; - white-space: pre-wrap; - margin: 6px 0 0; -} - -@media (prefers-color-scheme: dark) { - .change-card__sql pre { - background: rgba(255, 255, 255, 0.06); - } -} - -/* Actions */ - -.schema-editor__actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.schema-editor__actions button mat-icon { - margin-right: 4px; - margin-bottom: -4px; - font-size: 18px; - height: 18px; - width: 18px; -} - -/* Loading */ - -.schema-editor__loading { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 0; -} - -.schema-editor__loading-text { - font-size: 13px; - color: #9ca3af; -} - -.schema-editor__loading-dots { - display: flex; - gap: 6px; - align-items: center; -} - -.schema-editor__loading-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background-color: #9ca3af; - animation: loading-bounce 1.4s ease-in-out infinite; -} - -.schema-editor__loading-dot:nth-child(1) { animation-delay: 0s; } -.schema-editor__loading-dot:nth-child(2) { animation-delay: 0.15s; } -.schema-editor__loading-dot:nth-child(3) { animation-delay: 0.3s; } - -@keyframes loading-bounce { - 0%, 60%, 100% { transform: translateY(0); } - 30% { transform: translateY(-8px); } -} - -/* Open tables CTA */ - -.schema-editor__open-tables { - display: flex; - justify-content: center; - padding: 8px 16px; - border-top: 1px solid rgba(0, 0, 0, 0.06); - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__open-tables { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.schema-editor__open-tables button mat-icon { - margin-right: 4px; - margin-bottom: -4px; - font-size: 18px; - height: 18px; - width: 18px; -} - -/* Input form */ - -.schema-editor__form { - padding: 12px 16px 16px; - border-top: 1px solid rgba(0, 0, 0, 0.06); - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .schema-editor__form { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.schema-editor__input-field { - width: 100%; -} - -.schema-editor__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading { - border-radius: 24px 0 0 24px; - width: 24px; -} - -.schema-editor__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { - border-radius: 0 24px 24px 0; -} - -.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, -.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { - border-color: #6384ff !important; - border-width: 2px !important; -} - -.schema-editor__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { - border-color: #6384ff !important; - border-width: 2px 0 !important; -} - -.schema-editor__input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -.schema-editor__input-field ::ng-deep .mat-mdc-text-field-wrapper { - padding-bottom: 0 !important; -} - -.schema-editor__input-field textarea { - resize: none; -} - -.schema-editor__send-button { - color: #6384ff !important; -} - -/* Responsive: stack on narrow screens */ - -@media (max-width: 900px) { - .schema-editor__body { - flex-direction: column; - } - .schema-editor__panel { - width: 100%; - max-height: 50%; - } -} diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html deleted file mode 100644 index 237a29114..000000000 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.html +++ /dev/null @@ -1,279 +0,0 @@ -
- @if (isRoutedPage()) { -
- - arrow_back - -

Edit Database Schema

-
- } - @if (showClose) { - - } - -
- -
- @if (currentDiagram(); as diagram) { - - } @else if (initialDiagramLoading()) { -
- Loading diagram -
- - - -
-
- } @else { -
-
- schema -
-
-

Create your first table

-

Describe what you need and AI will generate the SQL

-
- -
-

or use a template

-
- - - - -
-
-
- } -
- - - -
-
diff --git a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts b/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts deleted file mode 100644 index 779580ab7..000000000 --- a/frontend/src/app/components/edit-database-schema/edit-database-schema.component.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { TextFieldModule } from '@angular/cdk/text-field'; -import { CommonModule } from '@angular/common'; -import { Component, computed, EventEmitter, Input, inject, OnInit, Output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { Parser } from 'node-sql-parser'; -import { SchemaChangeResponse, TableSchemaService } from 'src/app/services/table-schema.service'; -import { SchemaDiagramViewerComponent } from './schema-diagram-viewer/schema-diagram-viewer.component'; - -interface ParsedColumn { - name: string; - type: string; - pk: boolean; - fk: boolean; - referenceTable?: string; - notNull: boolean; - unique: boolean; - defaultValue?: string; -} - -interface ParsedChange { - id: string; - action: string; - icon: string; - tone: 'add' | 'edit' | 'remove'; - targetTableName: string; - aiSummary?: string; - columns: ParsedColumn[]; - forwardSql: string; -} - -interface ChatMessage { - role: 'user' | 'ai' | 'error' | 'diagram'; - text: string; - diagramSource?: string; - changes?: SchemaChangeResponse[]; - parsedChanges?: ParsedChange[]; - batchId?: string; -} - -@Component({ - selector: 'app-edit-database-schema', - templateUrl: './edit-database-schema.component.html', - styleUrls: ['./edit-database-schema.component.css'], - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatTooltipModule, - TextFieldModule, - RouterModule, - SchemaDiagramViewerComponent, - ], -}) -export class EditDatabaseSchemaComponent implements OnInit { - @Input() connectionID: string; - @Input() showClose: boolean = false; - @Output() schemaApplied = new EventEmitter(); - @Output() closeEditor = new EventEmitter(); - - private _tableSchema = inject(TableSchemaService); - private _route = inject(ActivatedRoute); - private _router = inject(Router); - - protected isRoutedPage = signal(false); - protected messages = signal([]); - protected userPrompt = signal(''); - protected submitting = signal(false); - protected applying = signal(false); - protected applied = signal(false); - protected initialDiagramLoading = signal(false); - private _threadId: string | undefined; - - protected pendingBatch = computed(() => { - const msgs = this.messages(); - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].batchId && msgs[i].changes?.length) return msgs[i]; - } - return null; - }); - - protected currentDiagram = computed(() => { - const msgs = this.messages(); - for (let i = msgs.length - 1; i >= 0; i--) { - if (msgs[i].role === 'diagram' && msgs[i].diagramSource) return msgs[i]; - } - return null; - }); - - protected chatMessages = computed(() => this.messages().filter((m) => m.role !== 'diagram')); - - ngOnInit(): void { - if (!this.connectionID) { - const id = this._route.snapshot.paramMap.get('connection-id'); - if (id) { - this.connectionID = id; - this.isRoutedPage.set(true); - this.showClose = false; - } else { - this._router.navigate(['/connections-list']); - return; - } - } - - if (this.showClose || this.isRoutedPage()) { - this._loadDiagram('Current Database Structure'); - } - } - - async onSubmit() { - const prompt = this.userPrompt().trim(); - if (!prompt || this.submitting()) return; - - this.messages.update((msgs) => [...msgs, { role: 'user', text: prompt }]); - this.userPrompt.set(''); - this.submitting.set(true); - - try { - const result = await this._tableSchema.generateSchemaChange(this.connectionID, prompt, this._threadId); - if (result.threadId) { - this._threadId = result.threadId; - } - if (result && result.changes.length > 0) { - this.applied.set(false); - let previewSource = ''; - try { - const preview = await this._tableSchema.previewDiagram( - this.connectionID, - result.changes.map((c) => c.forwardSql), - ); - previewSource = preview.diagram ?? ''; - } catch { - // preview is supplementary — don't block the change list on a diagram failure - } - const parsedChanges = result.changes.map((c) => this._parseChange(c)); - this.messages.update((msgs) => { - const next: ChatMessage[] = [ - ...msgs, - { - role: 'ai', - text: `I've generated ${result.changes.length} change(s) for your database. Review them below and approve or reject.`, - changes: result.changes, - parsedChanges, - batchId: result.batchId, - }, - ]; - if (previewSource) { - next.push({ - role: 'diagram', - text: 'Schema Preview', - diagramSource: '```mermaid\n' + previewSource + '\n```', - }); - } - return next; - }); - } else { - const clarification = result.assistantMessage?.trim(); - this.messages.update((msgs) => [ - ...msgs, - { - role: 'ai', - text: - clarification || - 'I could not generate any schema changes for that prompt. Could you describe your database in more detail?', - }, - ]); - } - } catch (err: unknown) { - const error = err as { error?: { message?: string }; message?: string }; - this.messages.update((msgs) => [ - ...msgs, - { - role: 'error', - text: error?.error?.message || error?.message || 'Failed to generate schema changes.', - }, - ]); - } finally { - this.submitting.set(false); - } - } - - async onApprove() { - const batch = this.pendingBatch(); - if (!batch?.batchId || this.applying()) return; - - this.applying.set(true); - - try { - const result = await this._tableSchema.approveBatch(batch.batchId, true); - if (result) { - const failed = result.changes.filter((c) => c.status === 'failed'); - if (failed.length > 0) { - this.messages.update((msgs) => [ - ...msgs, - { - role: 'error', - text: `${failed.length} change(s) failed: ${failed.map((c) => c.executionError).join('; ')}`, - }, - ]); - } else { - this.applied.set(true); - const autoFixed = result.changes.filter((c) => c.aiAutoFixApplied); - const successText = - autoFixed.length > 0 - ? `All changes applied successfully! ${autoFixed.length} statement(s) needed an AI repair before they could be applied — the originals failed against the database (e.g. ${autoFixed - .map((c) => `${c.targetTableName}: ${this._summarizeError(c.aiAutoFixOriginalError)}`) - .slice(0, 2) - .join('; ')}).` - : 'All changes applied successfully! Your tables have been created.'; - this.messages.update((msgs) => - msgs - .map((m) => (m === batch ? { ...m, batchId: undefined } : m)) - .concat({ - role: 'ai', - text: successText, - }), - ); - this._loadDiagram('Updated Database Structure'); - } - } - } catch (err: unknown) { - const error = err as { error?: { message?: string }; message?: string }; - this.messages.update((msgs) => [ - ...msgs, - { - role: 'error', - text: error?.error?.message || error?.message || 'Failed to apply schema changes.', - }, - ]); - } finally { - this.applying.set(false); - } - } - - async onReject() { - const batch = this.pendingBatch(); - if (!batch?.batchId) return; - - await this._tableSchema.rejectBatch(batch.batchId); - this.messages.update((msgs) => - msgs - .filter((m) => !(m.role === 'diagram' && m.text === 'Schema Preview')) - .map((m) => (m === batch ? { ...m, batchId: undefined } : m)) - .concat({ - role: 'ai', - text: 'Changes rejected. Feel free to describe what you need differently.', - }), - ); - } - - onOpenTables() { - this.schemaApplied.emit(); - } - - onKeydown(event: KeyboardEvent) { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - this.onSubmit(); - } - } - - private async _loadDiagram(label: string) { - this.initialDiagramLoading.set(true); - try { - const diagram = await this._tableSchema.fetchDiagram(this.connectionID); - const source = diagram?.diagram ?? ''; - if (this._mermaidHasEntities(source)) { - this.messages.update((msgs) => [ - ...msgs, - { - role: 'diagram' as const, - text: label, - diagramSource: '```mermaid\n' + source + '\n```', - }, - ]); - } - } catch { - // Diagram is supplementary - don't show error if it fails - } finally { - this.initialDiagramLoading.set(false); - } - } - - private _summarizeError(err: string | null | undefined): string { - if (!err) return 'unknown error'; - const compact = err.replace(/\s+/g, ' ').trim(); - return compact.length > 120 ? `${compact.slice(0, 120)}…` : compact; - } - - private _mermaidHasEntities(source: string): boolean { - if (!source) return false; - for (const line of source.split('\n')) { - if (/^\s*[A-Za-z_][\w-]*\s*\{/.test(line)) return true; - } - return false; - } - - private _extractColumnName(value: unknown): string | null { - if (!value) return null; - const inner = (value as { column?: unknown }).column ?? value; - if (typeof inner === 'string') return inner; - const expr = (inner as { expr?: { value?: unknown } })?.expr; - if (expr && typeof expr.value === 'string') return expr.value; - return null; - } - - private _extractReferenceTable(value: unknown): string | null { - if (!value) return null; - const inner = (value as { reference_definition?: unknown }).reference_definition ?? value; - const tables = (inner as { table?: unknown }).table; - const first = Array.isArray(tables) ? tables[0] : tables; - const name = (first as { table?: unknown })?.table; - return typeof name === 'string' ? name : null; - } - - private _changeMeta(changeType: string): { action: string; icon: string; tone: 'add' | 'edit' | 'remove' } { - const map: Record = { - CREATE_TABLE: { action: 'Create table', icon: 'add_box', tone: 'add' }, - DROP_TABLE: { action: 'Drop table', icon: 'delete_outline', tone: 'remove' }, - ADD_COLUMN: { action: 'Add column', icon: 'add', tone: 'add' }, - DROP_COLUMN: { action: 'Drop column', icon: 'remove', tone: 'remove' }, - ALTER_COLUMN: { action: 'Modify column', icon: 'edit', tone: 'edit' }, - ADD_INDEX: { action: 'Add index', icon: 'bookmark_add', tone: 'add' }, - DROP_INDEX: { action: 'Drop index', icon: 'bookmark_remove', tone: 'remove' }, - ADD_FOREIGN_KEY: { action: 'Add foreign key', icon: 'link', tone: 'add' }, - DROP_FOREIGN_KEY: { action: 'Drop foreign key', icon: 'link_off', tone: 'remove' }, - ADD_PRIMARY_KEY: { action: 'Add primary key', icon: 'vpn_key', tone: 'add' }, - DROP_PRIMARY_KEY: { action: 'Drop primary key', icon: 'key_off', tone: 'remove' }, - MONGO_CREATE_COLLECTION: { action: 'Create collection', icon: 'add_box', tone: 'add' }, - MONGO_DROP_COLLECTION: { action: 'Drop collection', icon: 'delete_outline', tone: 'remove' }, - MONGO_SET_VALIDATOR: { action: 'Set validator', icon: 'rule', tone: 'edit' }, - MONGO_CREATE_INDEX: { action: 'Create index', icon: 'bookmark_add', tone: 'add' }, - MONGO_DROP_INDEX: { action: 'Drop index', icon: 'bookmark_remove', tone: 'remove' }, - DYNAMODB_CREATE_TABLE: { action: 'Create table', icon: 'add_box', tone: 'add' }, - DYNAMODB_DROP_TABLE: { action: 'Drop table', icon: 'delete_outline', tone: 'remove' }, - DYNAMODB_UPDATE_TABLE: { action: 'Update table', icon: 'edit', tone: 'edit' }, - ELASTICSEARCH_CREATE_INDEX: { action: 'Create index', icon: 'add_box', tone: 'add' }, - ELASTICSEARCH_DELETE_INDEX: { action: 'Delete index', icon: 'delete_outline', tone: 'remove' }, - ELASTICSEARCH_UPDATE_MAPPING: { action: 'Update mapping', icon: 'edit', tone: 'edit' }, - ROLLBACK: { action: 'Rollback', icon: 'undo', tone: 'edit' }, - }; - const upper = (changeType ?? '').toUpperCase(); - return ( - map[upper] ?? { - action: upper - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/, (c) => c.toUpperCase()), - icon: 'code', - tone: 'edit', - } - ); - } - - private _parseChange(change: SchemaChangeResponse): ParsedChange { - const meta = this._changeMeta(change.changeType); - const parsed: ParsedChange = { - id: change.id, - action: meta.action, - icon: meta.icon, - tone: meta.tone, - targetTableName: change.targetTableName, - aiSummary: change.aiSummary, - columns: [], - forwardSql: change.forwardSql, - }; - if ((change.changeType ?? '').toUpperCase() === 'CREATE_TABLE' && change.forwardSql) { - parsed.columns = this._parseCreateTableColumns(change.forwardSql); - } - return parsed; - } - - private _parseCreateTableColumns(sql: string): ParsedColumn[] { - const parser = new Parser(); - let ast: unknown; - try { - ast = parser.astify(sql, { database: 'PostgresQL' }); - } catch { - try { - ast = parser.astify(sql, { database: 'MySQL' }); - } catch { - return []; - } - } - const nodes = Array.isArray(ast) ? ast : [ast]; - const columns: ParsedColumn[] = []; - const colIndex = new Map(); - for (const node of nodes) { - const root = node as Record | null; - if (!root || root['type'] !== 'create' || root['keyword'] !== 'table') continue; - const defs = (root['create_definitions'] as unknown[]) ?? []; - for (const rawDef of defs) { - const def = rawDef as Record; - if (def['resource'] === 'column') { - const name = this._extractColumnName(def['column']); - if (!name) continue; - const dataType = (def['definition'] as { dataType?: string } | undefined)?.dataType ?? ''; - const primary = def['primary'] as string | undefined; - const nullable = (def['nullable'] as { value?: string } | undefined)?.value; - const ref = this._extractReferenceTable(def['reference_definition']); - colIndex.set(name, columns.length); - columns.push({ - name, - type: dataType.toLowerCase(), - pk: primary === 'primary key' || primary === 'key', - fk: !!ref, - referenceTable: ref ?? undefined, - notNull: nullable === 'not null', - unique: !!def['unique'], - defaultValue: this._extractDefault(def['default_val']), - }); - } else if (def['resource'] === 'constraint') { - const ctype = (def['constraint_type'] as string | undefined)?.toLowerCase(); - const colRefs = (def['definition'] as unknown[]) ?? []; - if (ctype === 'primary key') { - for (const c of colRefs) { - const colName = this._extractColumnName(c); - if (!colName) continue; - const i = colIndex.get(colName); - if (i !== undefined) columns[i].pk = true; - } - } else if (ctype === 'foreign key') { - const refTable = this._extractReferenceTable(def['reference_definition']); - for (const c of colRefs) { - const colName = this._extractColumnName(c); - if (!colName) continue; - const i = colIndex.get(colName); - if (i !== undefined) { - columns[i].fk = true; - if (refTable) columns[i].referenceTable = refTable; - } - } - } - } - } - } - return columns; - } - - private _extractDefault(value: unknown): string | undefined { - if (!value || typeof value !== 'object') return undefined; - const inner = (value as { value?: { type?: string; value?: unknown } }).value; - if (!inner) return undefined; - if (typeof inner === 'string' || typeof inner === 'number' || typeof inner === 'boolean') return String(inner); - const v = (inner as { value?: unknown }).value; - if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v); - if ((inner as { type?: string }).type === 'function') { - const name = (inner as { name?: { name?: { value?: string }[] } }).name?.name?.[0]?.value ?? ''; - return name ? `${name.toUpperCase()}()` : undefined; - } - return undefined; - } -} diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css deleted file mode 100644 index 122e41070..000000000 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.css +++ /dev/null @@ -1,312 +0,0 @@ -:host { - display: flex; - width: 100%; - height: 100%; - min-height: 0; -} - -.schema-diagram { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 12px; - background: rgba(0, 0, 0, 0.02); - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram { - border-color: rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.03); - } -} - -/* Header / toolbar */ - -.schema-diagram__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); - background: transparent; - flex-wrap: wrap; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__header { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.schema-diagram__title { - font-size: 14px; - font-weight: 600; - margin: 0; -} - -.schema-diagram__toolbar { - display: flex; - align-items: center; - gap: 2px; -} - -.schema-diagram__toolbar-button--active { - background-color: rgba(99, 132, 255, 0.16); -} - -.schema-diagram__toolbar-divider { - width: 1px; - height: 20px; - background: rgba(0, 0, 0, 0.12); - margin: 0 6px; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__toolbar-divider { - background: rgba(255, 255, 255, 0.16); - } -} - -.schema-diagram__zoom-label { - font-size: 12px; - min-width: 42px; - text-align: center; - color: rgba(0, 0, 0, 0.6); - user-select: none; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__zoom-label { - color: rgba(255, 255, 255, 0.7); - } -} - -/* Body */ - -.schema-diagram__body { - flex: 1; - display: flex; - min-height: 0; -} - -/* Sidebar */ - -.schema-diagram__sidebar { - position: absolute; - top: 12px; - left: 12px; - z-index: 5; - width: 220px; - display: flex; - flex-direction: column; - max-height: calc(100% - 24px); - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 8px; - background: rgba(255, 255, 255, 0.88); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar { - border-color: rgba(255, 255, 255, 0.12); - background: rgba(20, 20, 28, 0.82); - } -} - -.schema-diagram__sidebar-search { - position: relative; - display: flex; - align-items: center; - padding: 10px 10px 8px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar-search { - border-color: rgba(255, 255, 255, 0.08); - } -} - -.schema-diagram__sidebar-search-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: rgba(0, 0, 0, 0.4); - position: absolute; - left: 18px; - pointer-events: none; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar-search-icon { - color: rgba(255, 255, 255, 0.5); - } -} - -.schema-diagram__sidebar-input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 999px; - padding: 6px 10px 6px 30px; - font-size: 13px; - background: transparent; - color: inherit; - outline: none; - width: 100%; -} - -.schema-diagram__sidebar-input:focus { - border-color: #6384ff; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar-input { - border-color: rgba(255, 255, 255, 0.16); - } -} - -.schema-diagram__sidebar-list { - overflow-y: auto; - padding: 6px; - display: flex; - flex-direction: column; - gap: 2px; -} - -.schema-diagram__sidebar-item { - display: flex; - align-items: center; - gap: 8px; - background: none; - border: none; - width: 100%; - text-align: left; - padding: 6px 10px; - border-radius: 6px; - font-size: 13px; - color: inherit; - cursor: pointer; - transition: background-color 0.15s ease; -} - -.schema-diagram__sidebar-item:hover { - background-color: rgba(99, 132, 255, 0.12); -} - -.schema-diagram__sidebar-item-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: rgba(0, 0, 0, 0.45); - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar-item-icon { - color: rgba(255, 255, 255, 0.55); - } -} - -.schema-diagram__sidebar-empty { - font-size: 12px; - color: rgba(0, 0, 0, 0.45); - margin: 12px; - text-align: center; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__sidebar-empty { - color: rgba(255, 255, 255, 0.45); - } -} - -/* Viewport */ - -.schema-diagram__viewport { - flex: 1; - position: relative; - overflow: hidden; - cursor: grab; - background-image: - radial-gradient(circle, rgba(0, 0, 0, 0.06) 1px, transparent 1px); - background-size: 18px 18px; -} - -@media (prefers-color-scheme: dark) { - .schema-diagram__viewport { - background-image: - radial-gradient(circle, rgba(255, 255, 255, 0.08) 1px, transparent 1px); - } -} - -.schema-diagram--panning .schema-diagram__viewport { - cursor: grabbing; -} - -.schema-diagram__canvas { - position: absolute; - top: 0; - left: 0; - transform-origin: 0 0; - will-change: transform; - line-height: 0; -} - -.schema-diagram__canvas ::ng-deep markdown, -.schema-diagram__canvas ::ng-deep markdown > * { - display: block; - margin: 0 !important; - padding: 0 !important; - line-height: 0; -} - -.schema-diagram__canvas ::ng-deep svg { - max-width: none !important; - max-height: none !important; - display: block; - margin: 0 !important; -} - -/* ── Dark-theme purple safety net for ER diagram ── */ - -@media (prefers-color-scheme: dark) { - .schema-diagram__canvas ::ng-deep svg rect, - .schema-diagram__canvas ::ng-deep svg .er.entityBox, - .schema-diagram__canvas ::ng-deep svg .er.attributeBoxOdd, - .schema-diagram__canvas ::ng-deep svg .er.attributeBoxEven { - fill: #1d143a !important; - stroke: #3d2b6e !important; - } - .schema-diagram__canvas ::ng-deep svg text, - .schema-diagram__canvas ::ng-deep svg tspan { - fill: #ede9fe !important; - stroke: none !important; - } - .schema-diagram__canvas ::ng-deep svg .er.relationshipLine, - .schema-diagram__canvas ::ng-deep svg path.relationshipLine { - stroke: #a78bfa !important; - } - .schema-diagram__canvas ::ng-deep svg marker path, - .schema-diagram__canvas ::ng-deep svg defs marker path { - fill: #a78bfa !important; - stroke: #a78bfa !important; - } -} - -.schema-diagram__canvas ::ng-deep .schema-diagram__highlight { - animation: highlight-pulse 1.6s ease; -} - -@keyframes highlight-pulse { - 0% { filter: drop-shadow(0 0 0 rgba(99, 132, 255, 0)); } - 30% { filter: drop-shadow(0 0 14px rgba(99, 132, 255, 0.8)); } - 100% { filter: drop-shadow(0 0 0 rgba(99, 132, 255, 0)); } -} - diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html deleted file mode 100644 index 4c0afd4d7..000000000 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.html +++ /dev/null @@ -1,62 +0,0 @@ -
-
-

{{ title }}

-
- - - - {{ scaleLabel() }} - - - -
-
- -
-
-
- -
- - @if (sidebarOpen()) { - - } - -
-
-
diff --git a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts b/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts deleted file mode 100644 index 095f9386f..000000000 --- a/frontend/src/app/components/edit-database-schema/schema-diagram-viewer/schema-diagram-viewer.component.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { - AfterViewInit, - Component, - ElementRef, - Input, - OnDestroy, - ViewChild, - computed, - signal, -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MarkdownModule } from 'ngx-markdown'; - -const MIN_RELATIVE = 0.2; -const MAX_RELATIVE = 8; -const ABSOLUTE_MIN = 0.05; -const ABSOLUTE_MAX = 40; -const ZOOM_STEP = 0.25; - -@Component({ - selector: 'app-schema-diagram-viewer', - templateUrl: './schema-diagram-viewer.component.html', - styleUrls: ['./schema-diagram-viewer.component.css'], - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatTooltipModule, - MarkdownModule, - ], -}) -export class SchemaDiagramViewerComponent implements AfterViewInit, OnDestroy { - @Input() title: string = ''; - @Input() set source(value: string) { - const next = value ?? ''; - if (this._source() === next) return; - this._source.set(next); - this._cachedSvg = null; - this.svgReady.set(false); - this.svgSize.set({ w: 0, h: 0 }); - this.scale.set(1); - this.baseScale.set(1); - this.translateX.set(0); - this.translateY.set(0); - } - get source(): string { - return this._source(); - } - - @ViewChild('viewport') viewportRef!: ElementRef; - @ViewChild('canvas') canvasRef!: ElementRef; - - protected _source = signal(''); - protected scale = signal(1); - protected baseScale = signal(1); - protected translateX = signal(0); - protected translateY = signal(0); - protected isPanning = signal(false); - protected tableSearch = signal(''); - protected sidebarOpen = signal(true); - protected svgReady = signal(false); - protected svgSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); - protected viewportSize = signal<{ w: number; h: number }>({ w: 0, h: 0 }); - - protected tables = computed(() => { - const src = this._source() - .replace(/^```mermaid\n?/i, '') - .replace(/```\s*$/i, ''); - const names: string[] = []; - const seen = new Set(); - for (const rawLine of src.split('\n')) { - const m = rawLine.match(/^\s*([A-Za-z_][\w-]*)\s*\{/); - if (m && !seen.has(m[1])) { - seen.add(m[1]); - names.push(m[1]); - } - } - return names.sort((a, b) => a.localeCompare(b)); - }); - - protected filteredTables = computed(() => { - const q = this.tableSearch().toLowerCase().trim(); - const list = this.tables(); - return q ? list.filter(t => t.toLowerCase().includes(q)) : list; - }); - - protected transformStyle = computed( - () => `translate(${this.translateX()}px, ${this.translateY()}px) scale(${this.scale()})`, - ); - - protected scaleLabel = computed(() => { - const base = this.baseScale() || 1; - return `${Math.round((this.scale() / base) * 100)}%`; - }); - - private _panStart = { x: 0, y: 0, tx: 0, ty: 0 }; - private _renderObserver: MutationObserver | null = null; - private _resizeObserver: ResizeObserver | null = null; - private _cachedSvg: SVGSVGElement | null = null; - private _onPanMove = (e: MouseEvent) => this._handlePanMove(e); - private _onPanEnd = () => this._handlePanEnd(); - - ngAfterViewInit(): void { - this._observeRender(); - this._observeResize(); - } - - ngOnDestroy(): void { - this._renderObserver?.disconnect(); - this._resizeObserver?.disconnect(); - document.removeEventListener('mousemove', this._onPanMove); - document.removeEventListener('mouseup', this._onPanEnd); - } - - onZoomIn() { - this._zoomAt(this.scale() + this.baseScale() * ZOOM_STEP); - } - - onZoomOut() { - this._zoomAt(this.scale() - this.baseScale() * ZOOM_STEP); - } - - onZoomReset() { - this.onFitToScreen(); - } - - onFitToScreen() { - let vp = this.viewportSize(); - if (!vp.w || !vp.h) { - const rect = this.viewportRef?.nativeElement?.getBoundingClientRect(); - if (rect && rect.width && rect.height) { - vp = { w: rect.width, h: rect.height }; - this.viewportSize.set(vp); - } - } - const svg = this.svgSize(); - if (!vp.w || !svg.w) return; - const padding = 24; - const fit = Math.min( - (vp.w - padding * 2) / svg.w, - (vp.h - padding * 2) / svg.h, - ); - const newScale = Math.max(ABSOLUTE_MIN, Math.min(ABSOLUTE_MAX, fit)); - this.baseScale.set(newScale); - this.scale.set(newScale); - this.translateX.set((vp.w - svg.w * newScale) / 2); - this.translateY.set((vp.h - svg.h * newScale) / 2); - } - - onPanStart(event: MouseEvent) { - const target = event.target as HTMLElement; - if (target.closest('button, a, input, .schema-diagram__sidebar, .schema-diagram__toolbar')) { - return; - } - this.isPanning.set(true); - this._panStart = { - x: event.clientX, - y: event.clientY, - tx: this.translateX(), - ty: this.translateY(), - }; - document.addEventListener('mousemove', this._onPanMove); - document.addEventListener('mouseup', this._onPanEnd); - event.preventDefault(); - } - - onWheel(event: WheelEvent) { - event.preventDefault(); - const rect = this.viewportRef.nativeElement.getBoundingClientRect(); - const mx = event.clientX - rect.left; - const my = event.clientY - rect.top; - const delta = -event.deltaY * 0.0025; - const factor = 1 + delta; - this._zoomAt(this.scale() * factor, mx, my); - } - - onDoubleClick(event: MouseEvent) { - const target = event.target as Element | null; - if (!target) return; - const node = this._findEntityNode(target); - if (node) this._focusElement(node); - } - - onFocusTable(name: string) { - const node = this._findNodeByName(name); - if (node) this._focusElement(node); - } - - onToggleSidebar() { - this.sidebarOpen.update(v => !v); - } - - private _handlePanMove(event: MouseEvent) { - const dx = event.clientX - this._panStart.x; - const dy = event.clientY - this._panStart.y; - this.translateX.set(this._panStart.tx + dx); - this.translateY.set(this._panStart.ty + dy); - } - - private _handlePanEnd() { - this.isPanning.set(false); - document.removeEventListener('mousemove', this._onPanMove); - document.removeEventListener('mouseup', this._onPanEnd); - } - - private _zoomAt(targetScale: number, originX?: number, originY?: number) { - const base = this.baseScale() || 1; - const minAbs = Math.max(ABSOLUTE_MIN, base * MIN_RELATIVE); - const maxAbs = Math.min(ABSOLUTE_MAX, base * MAX_RELATIVE); - const clamped = Math.max(minAbs, Math.min(maxAbs, targetScale)); - const oldScale = this.scale(); - if (clamped === oldScale) return; - const vp = this.viewportSize(); - const ox = originX ?? vp.w / 2; - const oy = originY ?? vp.h / 2; - const ratio = clamped / oldScale; - this.translateX.update(tx => ox - (ox - tx) * ratio); - this.translateY.update(ty => oy - (oy - ty) * ratio); - this.scale.set(clamped); - } - - private _observeRender() { - const canvas = this.canvasRef?.nativeElement; - if (!canvas) return; - const tryAttach = () => { - const svg = canvas.querySelector('svg') as SVGSVGElement | null; - if (!svg) return; - if (svg === this._cachedSvg) return; - this._cachedSvg = svg; - this._prepareSvg(svg); - this.svgReady.set(true); - requestAnimationFrame(() => this.onFitToScreen()); - setTimeout(() => this.onFitToScreen(), 80); - }; - tryAttach(); - this._renderObserver?.disconnect(); - this._renderObserver = new MutationObserver(() => tryAttach()); - this._renderObserver.observe(canvas, { childList: true, subtree: true }); - } - - private _observeResize() { - const viewport = this.viewportRef?.nativeElement; - if (!viewport) return; - this._resizeObserver = new ResizeObserver(() => { - const vpRect = viewport.getBoundingClientRect(); - this.viewportSize.set({ w: vpRect.width, h: vpRect.height }); - }); - this._resizeObserver.observe(viewport); - } - - private _prepareSvg(svg: SVGSVGElement) { - svg.removeAttribute('style'); - svg.style.display = 'block'; - svg.style.maxWidth = 'none'; - svg.style.maxHeight = 'none'; - svg.style.height = 'auto'; - svg.style.margin = '0'; - - const measure = () => { - const rect = svg.getBoundingClientRect(); - const currentScale = this.scale() || 1; - let width = rect.width / currentScale; - let height = rect.height / currentScale; - if (!width || !height) { - const bbox = svg.getBBox(); - width = width || bbox.width || 800; - height = height || bbox.height || 600; - } - this.svgSize.set({ w: width, h: height }); - }; - measure(); - requestAnimationFrame(measure); - } - - private _findEntityNode(target: Element): SVGGraphicsElement | null { - const selectors = [ - 'g.node', - 'g.er', - 'g[id^="entity-"]', - 'g.entity', - 'g.classGroup', - ]; - for (const sel of selectors) { - const match = target.closest(sel); - if (match) return match as SVGGraphicsElement; - } - return null; - } - - private _findNodeByName(name: string): SVGGraphicsElement | null { - const svg = this.canvasRef?.nativeElement?.querySelector('svg'); - if (!svg) return null; - const target = name.toLowerCase(); - const labels = svg.querySelectorAll('text, .er .entityLabel, .nodeLabel'); - let best: SVGGraphicsElement | null = null; - labels.forEach(label => { - if (best) return; - const text = (label.textContent ?? '').trim().toLowerCase(); - if (text === target) { - const node = (label.closest('g[id], g.node, g.er, g.entity') || - label.parentElement) as SVGGraphicsElement | null; - if (node) best = node; - } - }); - return best; - } - - private _focusElement(node: SVGGraphicsElement) { - const viewport = this.viewportRef?.nativeElement; - if (!viewport) return; - const vpRect = viewport.getBoundingClientRect(); - const nodeRect = node.getBoundingClientRect(); - const targetScale = Math.max(this.scale(), 1.2); - const ratio = targetScale / this.scale(); - const nodeCenterX = nodeRect.left - vpRect.left + nodeRect.width / 2; - const nodeCenterY = nodeRect.top - vpRect.top + nodeRect.height / 2; - const newCenterX = nodeCenterX * ratio + (this.translateX() * (ratio - 1)); - const newCenterY = nodeCenterY * ratio + (this.translateY() * (ratio - 1)); - this.scale.set(targetScale); - this.translateX.update(tx => tx + (vpRect.width / 2 - newCenterX)); - this.translateY.update(ty => ty + (vpRect.height / 2 - newCenterY)); - node.classList.add('schema-diagram__highlight'); - setTimeout(() => node.classList.remove('schema-diagram__highlight'), 1600); - } -} diff --git a/frontend/src/app/components/email-change/email-change.component.css b/frontend/src/app/components/email-change/email-change.component.css deleted file mode 100644 index 1ff352eac..000000000 --- a/frontend/src/app/components/email-change/email-change.component.css +++ /dev/null @@ -1,30 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - margin: 0 auto; - width: clamp(300px, 33vw, 600px); -} - -.new-email-form { - display: flex; - flex-direction: column; -} - -.email-input { - margin-top: 8px; - width: 100%; -} - -.new-email-button { - align-self: flex-end; -} diff --git a/frontend/src/app/components/email-change/email-change.component.html b/frontend/src/app/components/email-change/email-change.component.html deleted file mode 100644 index 7b80d1771..000000000 --- a/frontend/src/app/components/email-change/email-change.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
- -
-

Change your email

-
- - - -
-
-
diff --git a/frontend/src/app/components/email-change/email-change.component.spec.ts b/frontend/src/app/components/email-change/email-change.component.spec.ts deleted file mode 100644 index fd5c7d68f..000000000 --- a/frontend/src/app/components/email-change/email-change.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; -import { UserService } from 'src/app/services/user.service'; -import { EmailChangeComponent } from './email-change.component'; - -describe('EmailChangeComponent', () => { - let component: EmailChangeComponent; - let fixture: ComponentFixture; - let userService: UserService; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, FormsModule, EmailChangeComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(EmailChangeComponent); - component = fixture.componentInstance; - userService = TestBed.inject(UserService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should update email', () => { - component.token = '12345678'; - component.newEmail = 'new@email.com'; - const fakeUpdateEmail = vi.spyOn(userService, 'changeEmail').mockReturnValue(of()); - - component.updateEmail(); - expect(fakeUpdateEmail).toHaveBeenCalledWith('12345678', 'new@email.com'); - }); -}); diff --git a/frontend/src/app/components/email-change/email-change.component.ts b/frontend/src/app/components/email-change/email-change.component.ts deleted file mode 100644 index d4d9dd40c..000000000 --- a/frontend/src/app/components/email-change/email-change.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { UserService } from 'src/app/services/user.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; - -@Component({ - selector: 'app-email-change', - templateUrl: './email-change.component.html', - styleUrls: ['./email-change.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - EmailValidationDirective, - AlertComponent, - ], -}) -export class EmailChangeComponent implements OnInit { - public token: string; - public newEmail: string; - public submitting: boolean; - - constructor( - private _userService: UserService, - private route: ActivatedRoute, - private router: Router, - ) {} - - ngOnInit(): void { - this.route.paramMap - .pipe( - map((params: ParamMap) => { - this.token = params.get('change-token'); - }), - ) - .subscribe(); - } - - updateEmail() { - this.submitting = true; - this._userService.changeEmail(this.token, this.newEmail).subscribe( - () => { - this._userService.fetchUser().subscribe(() => this.router.navigate(['/user-settings'])); - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } -} diff --git a/frontend/src/app/components/email-verification/email-verification.component.css b/frontend/src/app/components/email-verification/email-verification.component.css deleted file mode 100644 index eb1ae07e3..000000000 --- a/frontend/src/app/components/email-verification/email-verification.component.css +++ /dev/null @@ -1,15 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.loader { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; -} diff --git a/frontend/src/app/components/email-verification/email-verification.component.html b/frontend/src/app/components/email-verification/email-verification.component.html deleted file mode 100644 index f59035016..000000000 --- a/frontend/src/app/components/email-verification/email-verification.component.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
-

Verifying...

-
diff --git a/frontend/src/app/components/email-verification/email-verification.component.spec.ts b/frontend/src/app/components/email-verification/email-verification.component.spec.ts deleted file mode 100644 index 5606bbfdd..000000000 --- a/frontend/src/app/components/email-verification/email-verification.component.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router'; -import { of } from 'rxjs'; -import { AuthService } from 'src/app/services/auth.service'; -import { EmailVerificationComponent } from './email-verification.component'; - -describe('EmailVerificationComponent', () => { - let component: EmailVerificationComponent; - let fixture: ComponentFixture; - let authService: AuthService; - // let mockLocalStorage; - let routerSpy; - - beforeEach(async () => { - routerSpy = { navigate: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, EmailVerificationComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: ActivatedRoute, - useValue: { - paramMap: of( - convertToParamMap({ - 'verification-token': '1234567890-abcd', - }), - ), - }, - }, - { provide: Router, useValue: routerSpy }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(EmailVerificationComponent); - component = fixture.componentInstance; - authService = TestBed.inject(AuthService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should verify email', async () => { - const fakeVerifyEmail = vi.spyOn(authService, 'verifyEmail').mockReturnValue(of()); - - component.ngOnInit(); - - expect(fakeVerifyEmail).toHaveBeenCalledWith('1234567890-abcd'); - // expect(routerSpy.navigate).toHaveBeenCalledWith(['/user-settings']); - }); -}); diff --git a/frontend/src/app/components/email-verification/email-verification.component.ts b/frontend/src/app/components/email-verification/email-verification.component.ts deleted file mode 100644 index 1c46b81af..000000000 --- a/frontend/src/app/components/email-verification/email-verification.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, ParamMap, Router, RouterModule } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { AuthService } from 'src/app/services/auth.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; - -@Component({ - selector: 'app-email-verification', - templateUrl: './email-verification.component.html', - styleUrls: ['./email-verification.component.css'], - imports: [CommonModule, FormsModule, RouterModule, AlertComponent], -}) -export class EmailVerificationComponent implements OnInit { - isVerified: boolean = null; - isLoggedin: boolean = null; - - constructor( - private _auth: AuthService, - private route: ActivatedRoute, - public router: Router, - ) {} - - ngOnInit(): void { - this.route.paramMap - .pipe( - map((params: ParamMap) => { - const verificationToken = params.get('verification-token'); - const expirationTime = localStorage.getItem('token_expiration'); - - this._auth.verifyEmail(verificationToken).subscribe(() => { - if (expirationTime) { - this.router.navigate(['/user-settings']); - } else { - this.router.navigate(['/login']); - } - this.isVerified = true; - }); - }), - ) - .subscribe(); - } -} diff --git a/frontend/src/app/components/feature-notification/feature-notification.component.css b/frontend/src/app/components/feature-notification/feature-notification.component.css deleted file mode 100644 index a13ff72c3..000000000 --- a/frontend/src/app/components/feature-notification/feature-notification.component.css +++ /dev/null @@ -1,358 +0,0 @@ -.feature-notification { - position: fixed; - bottom: 32px; - left: 32px; - z-index: 1000; - display: flex; - flex-direction: column; - padding: 16px; - border-radius: 8px; - background-color: #fff; - box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); - width: clamp(200px, 25vw, 400px); -} - -@media (prefers-color-scheme: dark) { - .feature-notification { - background-color: var(--surface-dark-color); - } -} - -@media (max-width: 640px) { - .feature-notification { - left: 12px; - right: 12px; - bottom: 12px; - width: auto; - max-width: 480px; - margin: 0 auto; - padding: 14px; - border-radius: 14px; - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2), 0 4px 8px rgba(0, 0, 0, 0.12); - } -} - -.feature-notification__animation { - width: 100%; - border-radius: 8px; - overflow: hidden; - margin-bottom: 16px; - color: #6384ff; -} - -.fn-svg { - display: block; - width: 100%; - height: auto; -} - -.feature-notification__title { - font-size: 20px !important; - font-weight: 600 !important; - line-height: 1.15em !important; -} - -.feature-notification__description { - color: rgba(0, 0, 0, 0.64); -} - -@media (prefers-color-scheme: dark) { - .feature-notification__description { - color: rgba(255, 255, 255, 0.64); - } -} - -.actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 16px; -} - -/* ──────────────────────────────────────────── */ -/* Three-act animation */ -/* Cycle: 7s */ -/* Act 1 (0–1.5s / 0–21%): empty + Add db */ -/* Act 2 (1.5–3.5s / 21–50%): chat + typing */ -/* Act 3 (3.5–5.5s / 50–79%): AI + diagram */ -/* Hold (5.5–7s / 79–100%): linger & loop */ -/* ──────────────────────────────────────────── */ - -.fn-placeholder, -.fn-add-button, -.fn-pointer-1, -.fn-click-ripple, -.fn-chat-title, -.fn-chat-divider, -.fn-input, -.fn-caret, -.fn-send-button, -.fn-typing-clip, -.fn-user-message, -.fn-ai-avatar, -.fn-table, -.fn-link, -.fn-schema-ready { - opacity: 0; -} - -/* === ACT 1 elements === */ - -.fn-placeholder, -.fn-act--1 > circle, -.fn-act--1 > path, -.fn-act--1 > text { - animation: fn-fade-act1 10s ease-in-out infinite; -} - -.fn-add-button { - animation: fn-add-btn 10s ease-in-out infinite; - transform-origin: 180px 93px; -} - -.fn-pointer-1 { - animation: fn-pointer-1 10s ease-in-out infinite; -} - -.fn-click-ripple { - animation: fn-click-ripple 10s ease-in-out infinite; -} - -@keyframes fn-fade-act1 { - 0% { opacity: 0; } - 3% { opacity: 1; } - 19% { opacity: 1; } - 22% { opacity: 0; } - 100% { opacity: 0; } -} - -@keyframes fn-add-btn { - 0% { opacity: 0; transform: scale(0.95); } - 3% { opacity: 1; transform: scale(1); } - 17% { opacity: 1; transform: scale(1); } - 18% { opacity: 1; transform: scale(0.93); } - 19% { opacity: 1; transform: scale(1); } - 22% { opacity: 0; transform: scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-pointer-1 { - 0%, 3% { opacity: 0; transform: translate(60px, 40px); } - 5% { opacity: 1; transform: translate(60px, 40px); } - 14% { opacity: 1; transform: translate(178px, 86px); } - 17% { opacity: 1; transform: translate(178px, 86px) scale(0.85); } - 19% { opacity: 1; transform: translate(178px, 86px) scale(1); } - 22% { opacity: 0; transform: translate(178px, 86px); } - 100% { opacity: 0; } -} - -@keyframes fn-click-ripple { - 0%, 17% { opacity: 0; r: 0; } - 18% { opacity: 0.85; r: 6; } - 21% { opacity: 0; r: 34; } - 100% { opacity: 0; r: 0; } -} - -/* === ACT 2 elements === */ - -.fn-chat-divider { - animation: fn-chat-frame 10s ease-in-out infinite; -} - -.fn-chat-title--initial { - animation: fn-chat-title-initial 10s ease-in-out infinite; -} - -.fn-chat-title--final { - animation: fn-chat-title-final 10s ease-in-out infinite; -} - -@keyframes fn-chat-title-initial { - 0%, 21% { opacity: 0; } - 24% { opacity: 1; } - 42% { opacity: 1; } - 45% { opacity: 0; } - 100% { opacity: 0; } -} - -@keyframes fn-chat-title-final { - 0%, 42% { opacity: 0; } - 45% { opacity: 1; } - 93% { opacity: 1; } - 100% { opacity: 0; } -} - -.fn-input { - animation: fn-input-in 10s ease-in-out infinite; -} - -.fn-typing-clip { - animation: fn-typing 10s linear infinite; -} - -.fn-caret { - animation: fn-caret-track 10s linear infinite; -} - -.fn-send-button { - animation: fn-send-button 10s ease-in-out infinite; - transform-origin: 326px 151px; -} - -.fn-user-message { - animation: fn-user-message 10s ease-in-out infinite; -} - -@keyframes fn-chat-frame { - 0%, 21% { opacity: 0; } - 24% { opacity: 1; } - 93% { opacity: 1; } - 100% { opacity: 0; } -} - -@keyframes fn-input-in { - 0%, 21% { opacity: 0; transform: translateY(8px); } - 24% { opacity: 1; transform: translateY(0); } - 48% { opacity: 1; transform: translateY(0); } - 50% { opacity: 0; transform: translateY(0); } - 100% { opacity: 0; } -} - -@keyframes fn-typing { - 0%, 25% { width: 0; } - 40% { width: 270px; } - 48% { width: 270px; } - 50% { width: 0; } - 100% { width: 0; } -} - -@keyframes fn-caret-track { - 0%, 25% { opacity: 0; transform: translateX(0); } - 27% { opacity: 1; transform: translateX(0); } - 40% { opacity: 1; transform: translateX(255px); } - 42% { opacity: 0; transform: translateX(255px); } - 44% { opacity: 1; transform: translateX(255px); } - 46% { opacity: 0; transform: translateX(255px); } - 48% { opacity: 1; transform: translateX(255px); } - 50% { opacity: 0; transform: translateX(255px); } - 100% { opacity: 0; } -} - -@keyframes fn-send-button { - 0%, 40% { opacity: 0; transform: scale(0.9); } - 43% { opacity: 1; transform: scale(0.9); } - 44% { opacity: 1; transform: scale(1.15); } - 46% { opacity: 1; transform: scale(1); } - 47% { opacity: 1; transform: scale(0.9); } - 48% { opacity: 1; transform: scale(1); } - 50% { opacity: 0; transform: scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-user-message { - 0%, 47% { opacity: 0; transform: translateY(80px) scale(0.95); } - 50% { opacity: 1; transform: translateY(0) scale(1); } - 93% { opacity: 1; transform: translateY(0) scale(1); } - 100% { opacity: 0; } -} - -/* === ACT 3 elements === */ - -.fn-ai-avatar { - animation: fn-ai-avatar 10s ease-in-out infinite; - transform-origin: 22px 50px; -} - -.fn-table--1 { animation: fn-table-1 10s ease-out infinite; transform-origin: 69px 107px; } -.fn-table--2 { animation: fn-table-2 10s ease-out infinite; transform-origin: 181px 91px; } -.fn-table--3 { animation: fn-table-3 10s ease-out infinite; transform-origin: 293px 109px; } - -.fn-link { - stroke-dasharray: 50; - stroke-dashoffset: 50; -} - -.fn-link--1 { animation: fn-link-1 10s ease-in-out infinite; } -.fn-link--2 { animation: fn-link-2 10s ease-in-out infinite; } - -.fn-schema-ready { animation: fn-schema-ready 10s ease-in-out infinite; } - -@keyframes fn-ai-avatar { - 0%, 53% { opacity: 0; transform: scale(0.7); } - 56% { opacity: 1; transform: scale(1.1); } - 58% { opacity: 1; transform: scale(1); } - 93% { opacity: 1; transform: scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-table-1 { - 0%, 56% { opacity: 0; transform: translateY(8px) scale(0.92); } - 60% { opacity: 1; transform: translateY(0) scale(1); } - 93% { opacity: 1; transform: translateY(0) scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-table-2 { - 0%, 60% { opacity: 0; transform: translateY(8px) scale(0.92); } - 64% { opacity: 1; transform: translateY(0) scale(1); } - 93% { opacity: 1; transform: translateY(0) scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-table-3 { - 0%, 64% { opacity: 0; transform: translateY(8px) scale(0.92); } - 68% { opacity: 1; transform: translateY(0) scale(1); } - 93% { opacity: 1; transform: translateY(0) scale(1); } - 100% { opacity: 0; } -} - -@keyframes fn-link-1 { - 0%, 68% { stroke-dashoffset: 50; opacity: 0; } - 70% { opacity: 1; } - 74% { stroke-dashoffset: 0; opacity: 1; } - 93% { stroke-dashoffset: 0; opacity: 1; } - 100% { stroke-dashoffset: 50; opacity: 0; } -} - -@keyframes fn-link-2 { - 0%, 72% { stroke-dashoffset: 50; opacity: 0; } - 74% { opacity: 1; } - 78% { stroke-dashoffset: 0; opacity: 1; } - 93% { stroke-dashoffset: 0; opacity: 1; } - 100% { stroke-dashoffset: 50; opacity: 0; } -} - -@keyframes fn-schema-ready { - 0%, 76% { opacity: 0; transform: translateY(4px); } - 80% { opacity: 1; transform: translateY(0); } - 93% { opacity: 1; transform: translateY(0); } - 100% { opacity: 0; } -} - -@media (prefers-reduced-motion: reduce) { - .fn-placeholder, - .fn-add-button, - .fn-pointer-1, - .fn-click-ripple, - .fn-chat-title--final, - .fn-chat-divider, - .fn-input, - .fn-caret, - .fn-send-button, - .fn-user-message, - .fn-ai-avatar, - .fn-table, - .fn-link, - .fn-schema-ready { - animation: none !important; - opacity: 1 !important; - transform: none !important; - stroke-dashoffset: 0 !important; - } - .fn-typing-clip { - animation: none !important; - width: 270px !important; - } - .fn-chat-title--initial { display: none !important; } - .fn-act--1 { display: none !important; } -} diff --git a/frontend/src/app/components/feature-notification/feature-notification.component.html b/frontend/src/app/components/feature-notification/feature-notification.component.html deleted file mode 100644 index 3084dcc15..000000000 --- a/frontend/src/app/components/feature-notification/feature-notification.component.html +++ /dev/null @@ -1,127 +0,0 @@ -
-
- -

Design schemas with AI

-

- Generate a new database from scratch or edit your existing one — just describe what you need. -

-
-
- -
-
diff --git a/frontend/src/app/components/feature-notification/feature-notification.component.spec.ts b/frontend/src/app/components/feature-notification/feature-notification.component.spec.ts deleted file mode 100644 index d3df47328..000000000 --- a/frontend/src/app/components/feature-notification/feature-notification.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { FeatureNotificationComponent } from './feature-notification.component'; - -describe('FeatureNotificationComponent', () => { - let component: FeatureNotificationComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FeatureNotificationComponent], - providers: [{ provide: ActivatedRoute, useValue: {} }], - }).compileComponents(); - - fixture = TestBed.createComponent(FeatureNotificationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/feature-notification/feature-notification.component.ts b/frontend/src/app/components/feature-notification/feature-notification.component.ts deleted file mode 100644 index 889077398..000000000 --- a/frontend/src/app/components/feature-notification/feature-notification.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Component, EventEmitter, Output } from '@angular/core'; - -import { MatButtonModule } from '@angular/material/button'; -import { RouterModule } from '@angular/router'; - -@Component({ - selector: 'app-feature-notification', - imports: [MatButtonModule, RouterModule], - templateUrl: './feature-notification.component.html', - styleUrl: './feature-notification.component.css', -}) -export class FeatureNotificationComponent { - @Output() dismiss = new EventEmitter(); -} diff --git a/frontend/src/app/components/login/login.component.css b/frontend/src/app/components/login/login.component.css deleted file mode 100644 index eaf34923d..000000000 --- a/frontend/src/app/components/login/login.component.css +++ /dev/null @@ -1,324 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - height: calc(100vh - 56px); - padding: 16px; -} - -@media (width <= 600px) { - .wrapper { - padding: 0; - width: 100vw; - } -} - -.login-page { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100%; -} - -@media (width <= 600px) { - .login-page { - justify-content: flex-start; - } -} - -.login-form { - display: grid; - grid-template-columns: 360px 52px 360px; - grid-template-rows: auto 64px 64px 64px auto; - justify-items: center; -} - -.login-form--native-login { - display: flex; - flex-direction: column; - align-items: center; - width: clamp(360px, 30%, 600px); -} - -@media (width <= 600px) { - .login-form { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 40px 9vw; - width: 100%; - } -} - -.login-header { - grid-column: 1 / span 3; - display: flex; - flex-direction: column; - align-items: center; - padding-bottom: 40px; -} - -@media (width <= 600px) { - .login-header { - padding-bottom: 0; - } -} - -.login-header__logo { - margin-bottom: 36px; - width: 44px; -} - -@media (width <= 600px) { - .login-header__logo { - margin-bottom: 12px; - } -} - -@media (prefers-color-scheme: dark) { - .login-header__logo path { - fill: white; - } -} - -.loginTitle { - font-weight: 600 !important; - margin-bottom: 40px !important; - text-align: center !important; -} - -.loginTitle__emphasis { - color: var(--color-accentedPalette-500); -} - -@media (width <= 600px) { - .login-header__directions { - display: none; - } -} - -[hidden] { - display: none !important; -} - -.login-form__email { - grid-column: 1; - grid-row: 2; - width: 100%; -} - -@media (width <= 600px) { - .login-form__email { - order: 1; - } -} - -.login-form__companies { - width: 100%; -} - -@media (width <= 600px) { - .login-form__companies { - order: 2; - } -} - -.login-form__password { - /* grid-column: 1; - grid-row: 3; */ - width: 100%; -} - -@media (width <= 600px) { - .login-form__password { - order: 3; - } -} - -.login-form__login-button { - grid-column: 1 / span 3; - grid-row: 5; - width: 360px; - margin-top: 40px; -} - -@media (width <= 600px) { - .login-form__login-button { - order: 4; - margin-top: 0; - width: 100%; - } -} - -.login-form__google-button { - grid-column: 3; - grid-row: 2; -} - -@media (width <= 600px) { - .login-form__google-button { - order: 6; - margin: 4px 0; - } -} - -.login-form__github-button-box { - grid-column: 3; - grid-row: 3; - width: 100%; -} - -@media (width <= 600px) { - .login-form__github-button-box { - order: 7; - margin: 4px 0; - } -} - -.login-form__github-button { - height: 40px; - width: 100%; -} - -.login-form__github-button ::ng-deep .mdc-button__label { - display: flex; - justify-content: space-between; - width: 100%; -} - -.login-form__github-caption { - flex: 1 0 auto; - text-align: center; -} - -.login-form__github-icon { - --mat-button-outlined-icon-spacing: 0; - --mat-button-outlined-icon-offset: 0; -} - -.login-form__github-button:disabled .login-form__github-icon { - filter: invert(0.85); -} - -@media (prefers-color-scheme: dark) { - .login-form__github-icon ::ng-deep svg path { - fill: #fff; - } -} - -.login-form__link { - grid-column: 1 / span 3; - grid-row: 6; - color: var(--color-accentedPalette-500); - margin-top: 48px; - text-decoration: none; -} - -@media (width <= 600px) { - .login-form__link { - order: 9; - } -} - -.divider { - grid-column: 2; - grid-row: 2 / span 3; - align-self: center; - position: relative; - background-color: #e8e8e8; - margin: 36px 0; - height: 100%; - width: 1px; -} - -@media (width <= 600px) { - .divider { - order: 5; - height: 1px; - margin: 28px 0 8px; - width: 100%; - } -} - -.divider__label { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: var(--mat-sidenav-content-background-color); - font-size: 14px; - padding: 0 16px; -} - -.qr-verification { - display: flex; - flex-direction: column; - width: 400px; -} - -.qr-verification__title { - text-align: center; - margin-bottom: 32px; -} - -.companiesRadioGroup { - display: flex; - flex-direction: column; - align-items: flex-start; - margin-bottom: 12px; -} - -.login-form__field-loader { - position: relative; - background-color: rgba(0, 0, 0, 0.06); - overflow: hidden; - height: 44px; - width: 100%; -} - -@media (width <= 600px) { - .login-form__field-loader { - order: 2; - } -} - -.login-form__field-loader::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - transform: translateX(-100%); - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #fff), - transparent 60% - ); - animation: shimmer 800ms ease-in-out infinite alternate; -} - -@media (prefers-color-scheme: dark) { - .login-form__field-loader::after { - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #303030), - transparent 60% - ); - } -} - -@keyframes shimmer { - 100% { - transform: translateX(100%); - } -} diff --git a/frontend/src/app/components/login/login.component.html b/frontend/src/app/components/login/login.component.html deleted file mode 100644 index 64745d7cd..000000000 --- a/frontend/src/app/components/login/login.component.html +++ /dev/null @@ -1,128 +0,0 @@ - - -
- -
- diff --git a/frontend/src/app/components/login/login.component.spec.ts b/frontend/src/app/components/login/login.component.spec.ts deleted file mode 100644 index 754c8d2ba..000000000 --- a/frontend/src/app/components/login/login.component.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatIconTestingModule } from '@angular/material/icon/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { AuthService } from 'src/app/services/auth.service'; -import { CompanyService } from 'src/app/services/company.service'; -import { LoginComponent } from './login.component'; - -describe('LoginComponent', () => { - let component: LoginComponent; - let fixture: ComponentFixture; - let authService: AuthService; - let companyService: CompanyService; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatSnackBarModule, - MatIconTestingModule, - Angulartics2Module.forRoot(), - LoginComponent, - BrowserAnimationsModule, - ], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - - (global.window as any).google = { - accounts: { - id: { - initialize: vi.fn(), - renderButton: vi.fn(), - prompt: vi.fn(), - }, - }, - }; - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LoginComponent); - component = fixture.componentInstance; - authService = TestBed.inject(AuthService); - companyService = TestBed.inject(CompanyService); - vi.spyOn(companyService, 'isCustomDomain').mockReturnValue(false); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should login a user', () => { - component.user = { - email: 'john@smith.com', - password: 'kK123456789', - companyId: 'company_1', - }; - - const fakeLoginUser = vi.spyOn(authService, 'loginUser').mockReturnValue(of()); - - component.loginUser(); - expect(fakeLoginUser).toHaveBeenCalledWith({ - email: 'john@smith.com', - password: 'kK123456789', - companyId: 'company_1', - }); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/login/login.component.ts b/frontend/src/app/components/login/login.component.ts deleted file mode 100644 index 3a32c4ae5..000000000 --- a/frontend/src/app/components/login/login.component.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, CUSTOM_ELEMENTS_SCHEMA, NgZone, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Router, RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { accounts } from 'google-one-tap'; -import posthog from 'posthog-js'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; -import { ExistingAuthUser } from 'src/app/models/user'; -import { AuthService } from 'src/app/services/auth.service'; -import { CompanyService } from 'src/app/services/company.service'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { environment } from 'src/environments/environment'; -import { AlertComponent } from '../ui-components/alert/alert.component'; - -declare var google: any; - -@Component({ - selector: 'app-login', - templateUrl: './login.component.html', - styleUrls: ['./login.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatIconModule, - MatButtonModule, - EmailValidationDirective, - AlertComponent, - Angulartics2OnModule, - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class LoginComponent implements OnInit, AfterViewInit { - protected posthog = posthog; - public isSaas = (environment as any).saas; - public isCustomDomain: boolean = false; - public user: ExistingAuthUser = { - email: '', - password: '', - companyId: '', - }; - public userCompanies: [] = null; - public isLoadingUserCompanies: boolean = false; - public authCode: string; - public submitting: boolean; - public isPasswordFieldShown: boolean = false; - public is2FAShown: boolean = false; - public errors = { - 'No_user_registered_with_this_GitHub_account.': 'No user registered with this GitHub account.', - 'GitHub_login_failed._Please_contact_our_support_team.': 'GitHub login failed. Please contact our support team.', - }; - - constructor( - private _auth: AuthService, - public router: Router, - private angulartics2: Angulartics2, - private ngZone: NgZone, - private _notifications: NotificationsService, - public _company: CompanyService, - ) {} - - ngOnInit(): void { - this.isCustomDomain = this._company.isCustomDomain() && this.isSaas; - - const error = new URLSearchParams(location.search).get('error'); - if (error) - this._notifications.showAlert(AlertType.Error, this.errors[error] || error, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - } - - ngAfterViewInit() { - if (this.isSaas) { - const gAccounts: accounts = google.accounts; - gAccounts.id.initialize({ - client_id: '681163285738-e4l0lrv5vv7m616ucrfhnhso9r396lum.apps.googleusercontent.com', - callback: (authUser) => { - this.ngZone.run(() => { - this._auth.loginWithGoogle(authUser.credential).subscribe((res) => { - if (res.isTemporary) this.is2FAShown = true; - this.angulartics2.eventTrack.next({ - action: 'Login: google login success', - }); - posthog.capture('Login: google login success'); - }); - }); - }, - }); - gAccounts.id.renderButton(document.getElementById('google_login_button'), { - theme: 'outline', - size: 'large', - width: 360, - text: 'continue_with', - }); - gAccounts.id.prompt(); - } - } - - requestUserCompanies() { - this.isLoadingUserCompanies = true; - this._auth.fetchUserCompanies(this.user.email).subscribe((companies) => { - this.angulartics2.eventTrack.next({ - action: 'Login: companies is received', - properties: { - count: companies.length, - }, - }); - posthog.capture('Login: companies is received', { - count: companies.length, - }); - - this.userCompanies = companies; - this.isLoadingUserCompanies = false; - - if (companies.length === 1) { - this.isPasswordFieldShown = true; - this.user.companyId = companies[0].id; - } - }); - } - - loginUser() { - this.submitting = true; - this._auth.loginUser(this.user).subscribe( - (res) => { - this.submitting = false; - if (res.isTemporary) this.is2FAShown = true; - this.angulartics2.eventTrack.next({ - action: 'Login: login success', - }); - posthog.capture('Login: login success'); - }, - (_error) => { - this.angulartics2.eventTrack.next({ - action: 'Login: login unsuccessful', - }); - posthog.capture('Login: login unsuccessful'); - this.submitting = false; - }, - () => (this.submitting = false), - ); - } - - loginWithGithub() { - this._auth.loginWithGithub(); - this.angulartics2.eventTrack.next({ - action: 'Login: github login redirect', - }); - posthog.capture('Login: github login redirect'); - } - - loginWith2FA() { - this.submitting = true; - this._auth.loginWith2FA(this.authCode).subscribe( - () => { - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Login: login with 2fa success', - }); - posthog.capture('Login: login with 2fa success'); - }, - (_error) => { - this.angulartics2.eventTrack.next({ - action: 'Login: login with 2fa unsuccessful', - }); - posthog.capture('Login: login with 2fa unsuccessful'); - this.submitting = false; - }, - () => (this.submitting = false), - ); - } -} diff --git a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.css b/frontend/src/app/components/master-password-dialog/master-password-dialog.component.css deleted file mode 100644 index fcaa8cf92..000000000 --- a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.mat-mdc-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.html b/frontend/src/app/components/master-password-dialog/master-password-dialog.component.html deleted file mode 100644 index b0f040f5f..000000000 --- a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.html +++ /dev/null @@ -1,13 +0,0 @@ -

Enter Master Password

-
- - - Password - - - -
- - -
-
diff --git a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.spec.ts b/frontend/src/app/components/master-password-dialog/master-password-dialog.component.spec.ts deleted file mode 100644 index 80620b280..000000000 --- a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { MasterPasswordDialogComponent } from './master-password-dialog.component'; - -describe('MasterPasswordDialogComponent', () => { - let component: MasterPasswordDialogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatDialogModule, - FormsModule, - MasterPasswordDialogComponent, - BrowserAnimationsModule, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: {} }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MasterPasswordDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.stories.ts b/frontend/src/app/components/master-password-dialog/master-password-dialog.component.stories.ts deleted file mode 100644 index 591e9d136..000000000 --- a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.stories.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MatDialogRef } from '@angular/material/dialog'; -import { Router } from '@angular/router'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { MasterPasswordDialogComponent } from './master-password-dialog.component'; - -const mockConnectionsService: Partial = { - currentConnectionID: 'conn-1', -}; - -const mockRouter: Partial = { - url: '/dashboard/conn-1/tables', - navigate: () => Promise.resolve(true), - routeReuseStrategy: { shouldReuseRoute: () => true } as any, - onSameUrlNavigation: 'ignore' as const, -}; - -const meta: Meta = { - title: 'Dialogs/MasterPassword', - component: MasterPasswordDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: Router, useValue: mockRouter }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.ts b/frontend/src/app/components/master-password-dialog/master-password-dialog.component.ts deleted file mode 100644 index f7eeab67e..000000000 --- a/frontend/src/app/components/master-password-dialog/master-password-dialog.component.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Router } from '@angular/router'; -import { ConnectionsService } from 'src/app/services/connections.service'; - -@Component({ - selector: 'app-master-password-dialog', - templateUrl: './master-password-dialog.component.html', - styleUrls: ['./master-password-dialog.component.css'], - imports: [FormsModule, MatFormFieldModule, MatDialogModule, MatInputModule, MatButtonModule], -}) -export class MasterPasswordDialogComponent implements OnInit { - public password: string = ''; - public connectionID: string | null = null; - - constructor( - public router: Router, - private dialogRef: MatDialogRef, - private _connections: ConnectionsService, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - } - - enterMasterPassword() { - localStorage.setItem(`${this.connectionID}__masterKey`, this.password); - let currentUrl = this.router.url; - this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.router.onSameUrlNavigation = 'reload'; - this.router.navigate([currentUrl]); - this.dialogRef.close(); - } -} diff --git a/frontend/src/app/components/page-not-found/page-not-found.component.css b/frontend/src/app/components/page-not-found/page-not-found.component.css deleted file mode 100644 index f59d0d424..000000000 --- a/frontend/src/app/components/page-not-found/page-not-found.component.css +++ /dev/null @@ -1,72 +0,0 @@ -.wrapper { - display: flex; - align-items: center; - background-image: url("../../../assets/bg/not-found-bg.svg"); - background-repeat: no-repeat; - background-size: auto 127%; - background-position: 100% 30%; - height: calc(100vh - 56px); - padding: 0 max(calc(20vw - 250px), 15%); - width: 100%; -} - -@media screen and (max-width: 600px) { - .wrapper { - background: none; - height: 100%; - padding: 0 4.5vw; - } - - .wrapper svg { - width: 55%; - } -} - -.message { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 36px; - width: clamp(300px, 60%, 600px); -} - -@media screen and (max-width: 600px) { - .message { - gap: 16px; - width: 100%; - } -} - -.message__title { - font-weight: 600; - margin-bottom: 16px; -} - -@media screen and (max-width: 600px) { - .message__title { - font-size: 32px; - line-height: 40px; - } -} - -.message__text { - font-size: 20px; - margin-bottom: 8px; -} - -@media screen and (max-width: 600px) { - .message__text { - font-size: 18px; - margin-top: 8px; - } -} - -.message__button { - width: 156px; -} - -@media screen and (max-width: 600px) { - .message__button { - margin-top: 16px; - } -} diff --git a/frontend/src/app/components/page-not-found/page-not-found.component.html b/frontend/src/app/components/page-not-found/page-not-found.component.html deleted file mode 100644 index 3042c25c3..000000000 --- a/frontend/src/app/components/page-not-found/page-not-found.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - - - We can't find the page you are looking for - Please reload the page or comeback later - - Back to Home - -
-
diff --git a/frontend/src/app/components/page-not-found/page-not-found.component.spec.ts b/frontend/src/app/components/page-not-found/page-not-found.component.spec.ts deleted file mode 100644 index f959d1247..000000000 --- a/frontend/src/app/components/page-not-found/page-not-found.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PageNotFoundComponent } from './page-not-found.component'; - -describe('PageNotFoundComponent', () => { - let component: PageNotFoundComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PageNotFoundComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PageNotFoundComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/page-not-found/page-not-found.component.ts b/frontend/src/app/components/page-not-found/page-not-found.component.ts deleted file mode 100644 index b8efbeed0..000000000 --- a/frontend/src/app/components/page-not-found/page-not-found.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -@Component({ - selector: 'app-page-not-found', - templateUrl: './page-not-found.component.html', - styleUrls: ['./page-not-found.component.css'], -}) -export class PageNotFoundComponent implements OnInit { - ngOnInit() {} -} diff --git a/frontend/src/app/components/password-change/password-change.component.css b/frontend/src/app/components/password-change/password-change.component.css deleted file mode 100644 index 9658f2dc7..000000000 --- a/frontend/src/app/components/password-change/password-change.component.css +++ /dev/null @@ -1,29 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; -} - -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - margin: 0 auto; - padding: 16px; - max-width: 420px; - min-width: 300px; - width: 90%; -} - -.password-input { - width: 100%; -} - -.actions { - display: flex; - justify-content: flex-end; - margin-top: 48px; -} - -.password-form__back { - margin-right: 20px; -} diff --git a/frontend/src/app/components/password-change/password-change.component.html b/frontend/src/app/components/password-change/password-change.component.html deleted file mode 100644 index d7cb7fe1e..000000000 --- a/frontend/src/app/components/password-change/password-change.component.html +++ /dev/null @@ -1,23 +0,0 @@ -
- -
-
-

Change password

- - Enter old password - - Password can not be empty. - - - - -
- Back - -
-
-
-
diff --git a/frontend/src/app/components/password-change/password-change.component.spec.ts b/frontend/src/app/components/password-change/password-change.component.spec.ts deleted file mode 100644 index f25a7b6c2..000000000 --- a/frontend/src/app/components/password-change/password-change.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Router } from '@angular/router'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; -import { Angulartics2, Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { CompanyMemberRole } from 'src/app/models/company'; -import { SubscriptionPlans } from 'src/app/models/user'; -import { UserService } from 'src/app/services/user.service'; -import { PasswordChangeComponent } from './password-change.component'; - -describe('PasswordChangeComponent', () => { - let component: PasswordChangeComponent; - let fixture: ComponentFixture; - let userService: UserService; - let routerSpy; - - beforeEach(async () => { - // routerSpy = {navigate: vi.fn()}; - const angulartics2Mock = { - eventTrack: { - next: () => {}, // Mocking the next method - }, - trackLocation: () => {}, // Mocking the trackLocation method - }; - - await TestBed.configureTestingModule({ - imports: [ - FormsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - BrowserAnimationsModule, - PasswordChangeComponent, - ], - providers: [ - provideHttpClient(), - { provide: IPasswordStrengthMeterService, useValue: {} }, - { provide: Router, useValue: routerSpy }, - { provide: Angulartics2, useValue: angulartics2Mock }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordChangeComponent); - component = fixture.componentInstance; - userService = TestBed.inject(UserService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should change password', async (_done) => { - component.oldPassword = 'hH12345678'; - component.newPassword = '12345678hH'; - component.currentUser = { - id: '9127389214', - email: 'my@email.com', - isActive: true, - subscriptionLevel: SubscriptionPlans.free, - is_2fa_enabled: false, - role: CompanyMemberRole.Member, - externalRegistrationProvider: null, - company: { - id: 'company_123', - }, - }; - const fakeChangePassword = vi.spyOn(userService, 'changePassword').mockReturnValue(of()); - - await component.updatePassword(); - expect(fakeChangePassword).toHaveBeenCalledWith('hH12345678', '12345678hH', 'my@email.com'); - }); -}); diff --git a/frontend/src/app/components/password-change/password-change.component.ts b/frontend/src/app/components/password-change/password-change.component.ts deleted file mode 100644 index 2ac4a14b7..000000000 --- a/frontend/src/app/components/password-change/password-change.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Router } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { User } from 'src/app/models/user'; -import { UserService } from 'src/app/services/user.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; - -@Component({ - selector: 'app-password-change', - templateUrl: './password-change.component.html', - styleUrls: ['./password-change.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - AlertComponent, - UserPasswordComponent, - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class PasswordChangeComponent implements OnInit { - public currentUser: User = null; - public oldPassword: string = ''; - public newPassword: string = ''; - public submitting: boolean = false; - - constructor( - private _userService: UserService, - public router: Router, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this._userService.cast.subscribe((user) => (this.currentUser = user)); - } - - updatePasswordField(updatedValue: string) { - this.newPassword = updatedValue; - } - - updatePassword() { - this.submitting = true; - this._userService.changePassword(this.oldPassword, this.newPassword, this.currentUser.email).subscribe( - () => { - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'Manual password change: password updated successfully', - }); - posthog.capture('Manual password change: password updated successfully'); - this.router.navigate(['/user-settings']); - }, - undefined, - () => (this.submitting = false), - ); - } -} diff --git a/frontend/src/app/components/password-request/password-request.component.css b/frontend/src/app/components/password-request/password-request.component.css deleted file mode 100644 index e6d4ff606..000000000 --- a/frontend/src/app/components/password-request/password-request.component.css +++ /dev/null @@ -1,92 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - margin: 0 auto; - padding: 16px; - max-width: 420px; - min-width: 300px; - width: 90%; -} - -@media (width <= 600px) { - .wrapper { - padding: 16px 9vw; - width: 100%; - } -} - -.password-form { - display: flex; - flex-direction: column; - justify-content: center; - margin-top: -16px; -} - -.request-text { - margin-bottom: 20px; -} - -.email-field { - margin-top: 8px; -} - -.change-password-button { - margin-top: 20px; -} - -.password-form__field-loader { - position: relative; - background-color: rgba(0, 0, 0, 0.06); - margin-bottom: 16px; - overflow: hidden; - height: 44px; - width: 100%; -} - -.password-form__field-loader::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - transform: translateX(-100%); - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #fff), - transparent 60% - ); - animation: shimmer 800ms ease-in-out infinite alternate; -} - -@media (prefers-color-scheme: dark) { - .password-form__field-loader::after { - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #303030), - transparent 60% - ); - } -} - -@keyframes shimmer { - 100% { - transform: translateX(100%); - } -} - -.password-form__reset-button { - margin-top: 8px; -} diff --git a/frontend/src/app/components/password-request/password-request.component.html b/frontend/src/app/components/password-request/password-request.component.html deleted file mode 100644 index b65961ea9..000000000 --- a/frontend/src/app/components/password-request/password-request.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
- -
-
-

Forgot your password?

-

- Enter your Email below and we will send a message to reset your password. -

- - -
- - Company - - - {{company.name}} - - - - - - -
-
-
diff --git a/frontend/src/app/components/password-request/password-request.component.spec.ts b/frontend/src/app/components/password-request/password-request.component.spec.ts deleted file mode 100644 index 88872f7dd..000000000 --- a/frontend/src/app/components/password-request/password-request.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; -import { UserService } from 'src/app/services/user.service'; -import { PasswordRequestComponent } from './password-request.component'; - -describe('PasswordRequestComponent', () => { - let component: PasswordRequestComponent; - let fixture: ComponentFixture; - let userService: UserService; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RouterTestingModule, FormsModule, MatSnackBarModule, PasswordRequestComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordRequestComponent); - component = fixture.componentInstance; - userService = TestBed.inject(UserService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should create', () => { - component.userEmail = 'eric@cartman.ass'; - component.companyId = 'company_1111'; - const fakePasswordReset = vi.spyOn(userService, 'requestPasswordReset').mockReturnValue(of()); - - component.requestPassword(); - - expect(fakePasswordReset).toHaveBeenCalledWith('eric@cartman.ass', 'company_1111'); - }); -}); diff --git a/frontend/src/app/components/password-request/password-request.component.ts b/frontend/src/app/components/password-request/password-request.component.ts deleted file mode 100644 index cee3d734c..000000000 --- a/frontend/src/app/components/password-request/password-request.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { AuthService } from 'src/app/services/auth.service'; -import { UserService } from 'src/app/services/user.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; - -@Component({ - selector: 'app-password-request', - templateUrl: './password-request.component.html', - styleUrls: ['./password-request.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatButtonModule, - EmailValidationDirective, - AlertComponent, - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class PasswordRequestComponent implements OnInit { - public userEmail: string; - public companyId: string; - public submitting: boolean; - public isLoadingUserCompanies: boolean = false; - public userCompanies: [] = null; - - constructor( - private _userService: UserService, - private _auth: AuthService, - ) {} - - ngOnInit(): void {} - - requestUserCompanies() { - this.isLoadingUserCompanies = true; - this._auth.fetchUserCompanies(this.userEmail).subscribe((companies) => { - this.userCompanies = companies; - this.isLoadingUserCompanies = false; - - if (companies.length === 1) { - this.companyId = companies[0].id; - } - }); - } - - requestPassword() { - this.submitting = true; - this._userService.requestPasswordReset(this.userEmail, this.companyId).subscribe( - () => { - this.submitting = false; - }, - () => (this.submitting = false), - () => (this.submitting = false), - ); - } -} diff --git a/frontend/src/app/components/password-reset/password-reset.component.css b/frontend/src/app/components/password-reset/password-reset.component.css deleted file mode 100644 index d5b408da3..000000000 --- a/frontend/src/app/components/password-reset/password-reset.component.css +++ /dev/null @@ -1,54 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - /* margin: 80px auto; */ - height: 100%; - width: 100%; -} - -@media (width <= 600px) { - .wrapper { - padding: 16px 9vw; - } -} - -.new-password-form { - display: flex; - flex-direction: column; - /* align-items: flex-start; */ - margin-top: -16px; - width: clamp(300px, 33vw, 600px); -} - -@media (width <= 600px) { - .new-password-form { - width: 100%; - } -} - -.password-input { - /* align-self: stretch; */ - margin-top: 8px; - width: 100%; -} - -.new-password-button { - align-self: flex-end; - margin-top: 8px; -} - -@media screen and (max-width: 1401px) { - .new-password-button { - margin-top: 48px; - } -} diff --git a/frontend/src/app/components/password-reset/password-reset.component.html b/frontend/src/app/components/password-reset/password-reset.component.html deleted file mode 100644 index 60353fd93..000000000 --- a/frontend/src/app/components/password-reset/password-reset.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
- -
-
-

Change your password

- - - -
-
-
diff --git a/frontend/src/app/components/password-reset/password-reset.component.spec.ts b/frontend/src/app/components/password-reset/password-reset.component.spec.ts deleted file mode 100644 index 308097d63..000000000 --- a/frontend/src/app/components/password-reset/password-reset.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; -import { Angulartics2Module } from 'angulartics2'; -import { PasswordResetComponent } from './password-reset.component'; - -describe('PasswordResetComponent', () => { - let component: PasswordResetComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - PasswordResetComponent, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - ], - providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordResetComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/password-reset/password-reset.component.ts b/frontend/src/app/components/password-reset/password-reset.component.ts deleted file mode 100644 index 6f72e27fb..000000000 --- a/frontend/src/app/components/password-reset/password-reset.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { map } from 'rxjs/operators'; -import { UserService } from 'src/app/services/user.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; - -@Component({ - selector: 'app-password-reset', - templateUrl: './password-reset.component.html', - styleUrls: ['./password-reset.component.css'], - imports: [CommonModule, FormsModule, MatButtonModule, AlertComponent, UserPasswordComponent], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class PasswordResetComponent implements OnInit { - public token: string; - public newPassword: string = ''; - public submitting: boolean; - - constructor( - private router: Router, - private route: ActivatedRoute, - private _userService: UserService, - ) {} - - ngOnInit(): void { - this.route.paramMap - .pipe( - map((params: ParamMap) => { - this.token = params.get('verification-token'); - }), - ) - .subscribe(); - } - - updatePasswordField(updatedValue: string) { - this.newPassword = updatedValue; - } - - updatePassword() { - this._userService.resetPassword(this.token, this.newPassword).subscribe(() => { - this.router.navigate(['/login']); - }); - } -} diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css deleted file mode 100644 index 92a7d456a..000000000 --- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.css +++ /dev/null @@ -1,130 +0,0 @@ -.profile-sidebar { - width: 220px; - min-width: 220px; - height: 100%; - border-right: 1px solid var(--mat-sidenav-container-divider-color); - display: flex; - flex-direction: column; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .profile-sidebar { - background-color: var(--surface-dark-color); - } -} - -.profile-sidebar_initialized { - transition: - width 0.2s ease-in-out, - min-width 0.2s ease-in-out; -} - -.profile-sidebar_collapsed { - width: 56px; - min-width: 56px; -} - -.sidebar-toggle-button { - align-self: flex-start; - margin-top: 8px; - margin-left: 12px; -} - -.sidebar-toggle-button_collapsed { - justify-content: center; - margin: 12px 8px; -} - -.sidebar-nav { - padding-top: 0; - overflow: hidden; -} - -.sidebar-nav-item { - margin: 4px 8px; - border-radius: 8px; - overflow: hidden; -} - -.sidebar-nav-item ::ng-deep .mdc-list-item__primary-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sidebar-nav-item { - --mdc-list-list-item-leading-icon-end-space: 8px; -} - -.sidebar-nav-item mat-icon { - font-size: 20px; - width: 20px; - height: 20px; -} - -.sidebar-nav-item ::ng-deep .mdc-list-item__start { - margin-right: 8px; -} - -.sidebar-nav-item_collapsed { - margin: 4px; -} - -.sidebar-nav-item_collapsed ::ng-deep .mdc-list-item__content { - padding: 0; - display: flex; - justify-content: center; -} - -.active-link { - background-color: var(--color-accentedPalette-100); -} - -@media (prefers-color-scheme: dark) { - .active-link { - background-color: var(--color-accentedPalette-800); - } -} - -.active-link mat-icon { - color: var(--color-accentedPalette-500); -} - -.sidebar-group-label { - display: block; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.3px; - color: rgba(0, 0, 0, 0.4); - padding: 12px 16px 4px; -} - -@media (prefers-color-scheme: dark) { - .sidebar-group-label { - color: rgba(255, 255, 255, 0.35); - } -} - -.sidebar-divider { - height: 1px; - background-color: rgba(0, 0, 0, 0.08); - margin: 4px 16px; -} - -.sidebar-divider_collapsed { - margin: 4px 8px; -} - -@media (prefers-color-scheme: dark) { - .sidebar-divider { - background-color: rgba(255, 255, 255, 0.08); - } -} - -/* Mobile styles */ -@media (width <= 600px) { - .profile-sidebar { - display: none; - } -} diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html deleted file mode 100644 index 0c8431d37..000000000 --- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html +++ /dev/null @@ -1,80 +0,0 @@ -
- - - - - - - - General - - person - Account - - - apartment - Company - - - - Security - - key - Secrets - - - - Developer - - vpn_key - API Keys - - - electric_bolt - Zapier - - -
diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts deleted file mode 100644 index 34e2825d9..000000000 --- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, inject, input, OnInit, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { environment } from 'src/environments/environment'; -import { UiSettingsService } from '../../../services/ui-settings.service'; - -@Component({ - selector: 'app-profile-sidebar', - templateUrl: './profile-sidebar.component.html', - styleUrls: ['./profile-sidebar.component.css'], - imports: [CommonModule, RouterModule, MatListModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class ProfileSidebarComponent implements OnInit, AfterViewInit { - activeTab = input<'account' | 'company' | 'api' | 'secrets' | 'zapier'>('account'); - - collapsed = signal(false); - initialized = signal(false); - isSaas = environment.saas; - - private _uiSettings = inject(UiSettingsService); - - ngOnInit(): void { - this._loadCollapsedState(); - } - - ngAfterViewInit(): void { - setTimeout(() => this.initialized.set(true), 0); - } - - toggleCollapsed(): void { - this.collapsed.update((v) => { - const newValue = !v; - this._uiSettings.updateGlobalSetting('profileSidebarCollapsed', newValue); - return newValue; - }); - } - - private _loadCollapsedState(): void { - this._uiSettings.getUiSettings().subscribe((settings) => { - this.collapsed.set(settings?.globalSettings?.profileSidebarCollapsed ?? false); - }); - } -} diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css deleted file mode 100644 index 2fd279a4c..000000000 --- a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.css +++ /dev/null @@ -1,176 +0,0 @@ -h2[mat-dialog-title] { - display: flex; - align-items: center; - gap: 8px; -} - -.title-icon { - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .title-icon { - color: rgba(255, 255, 255, 0.7); - } -} - -mat-dialog-content { - min-width: 600px; - max-height: 60vh; -} - -@media (width <= 800px) { - mat-dialog-content { - min-width: auto; - } -} - -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 40px; - text-align: center; -} - -.loading-container p { - margin-top: 16px; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .loading-container p { - color: rgba(255, 255, 255, 0.54); - } -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 24px; - text-align: center; -} - -.empty-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: rgba(0, 0, 0, 0.26); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .empty-icon { - color: rgba(255, 255, 255, 0.3); - } -} - -.empty-state h3 { - margin: 0 0 8px 0; -} - -.empty-state p { - margin: 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .empty-state p { - color: rgba(255, 255, 255, 0.54); - } -} - -.audit-log-table { - width: 100%; -} - -.action-cell { - display: flex; - align-items: center; - gap: 8px; -} - -.action-cell mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.action-create { - color: #2e7d32; -} - -@media (prefers-color-scheme: dark) { - .action-create { - color: #81c784; - } -} - -.action-view, -.action-copy { - color: #1976d2; -} - -@media (prefers-color-scheme: dark) { - .action-view, - .action-copy { - color: #64b5f6; - } -} - -.action-update { - color: #ef6c00; -} - -@media (prefers-color-scheme: dark) { - .action-update { - color: #ffb74d; - } -} - -.action-delete { - color: #c62828; -} - -@media (prefers-color-scheme: dark) { - .action-delete { - color: #ef5350; - } -} - -.user-email { - font-size: 14px; -} - -.success-icon { - color: #2e7d32; -} - -@media (prefers-color-scheme: dark) { - .success-icon { - color: #81c784; - } -} - -.failure-icon { - color: #c62828; -} - -@media (prefers-color-scheme: dark) { - .failure-icon { - color: #ef5350; - } -} - -.failed-row { - background-color: rgba(198, 40, 40, 0.05); -} - -@media (prefers-color-scheme: dark) { - .failed-row { - background-color: rgba(239, 83, 80, 0.1); - } -} diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html deleted file mode 100644 index 21ffe6c11..000000000 --- a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.html +++ /dev/null @@ -1,73 +0,0 @@ -

- history - Audit Log: {{data.secret.slug}} -

- - - -
- -

Loading audit log...

-
- - -
- history_toggle_off -

No audit log entries

-

No actions have been recorded for this secret yet.

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Action -
- {{actionIcons[log.action]}} - {{actionLabels[log.action]}} -
-
User - {{log.user.email}} - Time - {{log.accessedAt | date:'medium'}} - Status - check_circle - error -
- - - -
- - - - diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts deleted file mode 100644 index 0098e8f40..000000000 --- a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.spec.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { PageEvent } from '@angular/material/paginator'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { AuditLogEntry, AuditLogResponse, Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { AuditLogDialogComponent } from './audit-log-dialog.component'; - -describe('AuditLogDialogComponent', () => { - let component: AuditLogDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: { getAuditLog: ReturnType }; - let mockDialogRef: { close: ReturnType }; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - const mockAuditLogEntry: AuditLogEntry = { - id: '1', - action: 'create', - user: { id: '1', email: 'user@example.com' }, - accessedAt: '2024-01-01T00:00:00Z', - success: true, - }; - - const createMockAuditLogResponse = (): AuditLogResponse => ({ - data: [mockAuditLogEntry], - pagination: { total: 1, currentPage: 1, perPage: 20, lastPage: 1 }, - }); - - const createMockMultipleLogsResponse = (): AuditLogResponse => ({ - data: [ - mockAuditLogEntry, - { - id: '2', - action: 'view', - user: { id: '2', email: 'viewer@example.com' }, - accessedAt: '2024-01-02T00:00:00Z', - success: true, - }, - { - id: '3', - action: 'update', - user: { id: '1', email: 'user@example.com' }, - accessedAt: '2024-01-03T00:00:00Z', - success: true, - }, - { - id: '4', - action: 'copy', - user: { id: '3', email: 'copier@example.com' }, - accessedAt: '2024-01-04T00:00:00Z', - success: true, - }, - { - id: '5', - action: 'delete', - user: { id: '1', email: 'user@example.com' }, - accessedAt: '2024-01-05T00:00:00Z', - success: false, - errorMessage: 'Deletion failed', - }, - ], - pagination: { total: 5, currentPage: 1, perPage: 20, lastPage: 1 }, - }); - - beforeEach(async () => { - mockSecretsService = { - getAuditLog: vi.fn().mockImplementation(() => of(createMockAuditLogResponse())), - }; - - mockDialogRef = { close: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [AuditLogDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { secret: mockSecret } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(AuditLogDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('component initialization', () => { - it('should load audit log on init', () => { - expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 1, 20); - }); - - it('should display audit log entries', () => { - expect(component.logs.length).toBe(1); - expect(component.logs[0].action).toBe('create'); - }); - - it('should initialize with default pagination', () => { - expect(component.pagination).toEqual({ - total: 1, - currentPage: 1, - perPage: 20, - lastPage: 1, - }); - }); - - it('should initialize loading as false after load', () => { - expect(component.loading).toBe(false); - }); - - it('should have correct displayed columns', () => { - expect(component.displayedColumns).toEqual(['action', 'user', 'accessedAt', 'success']); - }); - }); - - describe('action labels', () => { - it('should have label for create action', () => { - expect(component.actionLabels.create).toBe('Created'); - }); - - it('should have label for view action', () => { - expect(component.actionLabels.view).toBe('Viewed'); - }); - - it('should have label for copy action', () => { - expect(component.actionLabels.copy).toBe('Copied'); - }); - - it('should have label for update action', () => { - expect(component.actionLabels.update).toBe('Updated'); - }); - - it('should have label for delete action', () => { - expect(component.actionLabels.delete).toBe('Deleted'); - }); - }); - - describe('action icons', () => { - it('should have icon for create action', () => { - expect(component.actionIcons.create).toBe('add_circle'); - }); - - it('should have icon for view action', () => { - expect(component.actionIcons.view).toBe('visibility'); - }); - - it('should have icon for copy action', () => { - expect(component.actionIcons.copy).toBe('content_copy'); - }); - - it('should have icon for update action', () => { - expect(component.actionIcons.update).toBe('edit'); - }); - - it('should have icon for delete action', () => { - expect(component.actionIcons.delete).toBe('delete'); - }); - }); - - describe('action colors', () => { - it('should have color for create action', () => { - expect(component.actionColors.create).toBe('primary'); - }); - - it('should have color for view action', () => { - expect(component.actionColors.view).toBe('accent'); - }); - - it('should have color for copy action', () => { - expect(component.actionColors.copy).toBe('accent'); - }); - - it('should have color for update action', () => { - expect(component.actionColors.update).toBe('primary'); - }); - - it('should have color for delete action', () => { - expect(component.actionColors.delete).toBe('warn'); - }); - }); - - describe('loadAuditLog', () => { - it('should set loading to true while fetching', () => { - component.loading = false; - mockSecretsService.getAuditLog.mockImplementation(() => of(createMockAuditLogResponse())); - - component.loadAuditLog(); - - expect(component.loading).toBe(false); - }); - - it('should update logs on successful fetch', () => { - mockSecretsService.getAuditLog.mockImplementation(() => of(createMockMultipleLogsResponse())); - - component.loadAuditLog(); - - expect(component.logs.length).toBe(5); - }); - - it('should update pagination on successful fetch', () => { - mockSecretsService.getAuditLog.mockImplementation(() => of(createMockMultipleLogsResponse())); - - component.loadAuditLog(); - - expect(component.pagination.total).toBe(5); - }); - - it('should call getAuditLog with current pagination', () => { - mockSecretsService.getAuditLog.mockClear(); - component.pagination.currentPage = 2; - component.pagination.perPage = 10; - - component.loadAuditLog(); - - expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 2, 10); - }); - }); - - describe('onPageChange', () => { - it('should update pagination and reload audit log', () => { - const pageEvent: PageEvent = { - pageIndex: 2, - pageSize: 50, - length: 100, - }; - - // Update mock to return pagination matching the page change - mockSecretsService.getAuditLog.mockImplementation(() => - of({ - data: [mockAuditLogEntry], - pagination: { total: 100, currentPage: 3, perPage: 50, lastPage: 2 }, - }), - ); - mockSecretsService.getAuditLog.mockClear(); - component.onPageChange(pageEvent); - - expect(component.pagination.currentPage).toBe(3); - expect(component.pagination.perPage).toBe(50); - expect(mockSecretsService.getAuditLog).toHaveBeenCalledWith('test-secret', 3, 50); - }); - - it('should handle first page correctly', () => { - const pageEvent: PageEvent = { - pageIndex: 0, - pageSize: 20, - length: 100, - }; - - mockSecretsService.getAuditLog.mockClear(); - component.onPageChange(pageEvent); - - expect(component.pagination.currentPage).toBe(1); - }); - }); - - describe('with multiple audit log entries', () => { - beforeEach(() => { - mockSecretsService.getAuditLog.mockImplementation(() => of(createMockMultipleLogsResponse())); - component.loadAuditLog(); - }); - - it('should display all entries', () => { - expect(component.logs.length).toBe(5); - }); - - it('should include failed actions', () => { - const failedAction = component.logs.find((log) => !log.success); - expect(failedAction).toBeTruthy(); - expect(failedAction?.errorMessage).toBe('Deletion failed'); - }); - - it('should include all action types', () => { - const actions = component.logs.map((log) => log.action); - expect(actions).toContain('create'); - expect(actions).toContain('view'); - expect(actions).toContain('update'); - expect(actions).toContain('copy'); - expect(actions).toContain('delete'); - }); - }); - - describe('data binding', () => { - it('should have access to secret data', () => { - expect(component.data.secret).toEqual(mockSecret); - }); - - it('should have access to secret slug', () => { - expect(component.data.secret.slug).toBe('test-secret'); - }); - }); -}); diff --git a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts b/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts deleted file mode 100644 index 3c31b0b7d..000000000 --- a/frontend/src/app/components/secrets/audit-log-dialog/audit-log-dialog.component.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { AuditLogEntry, Secret, SecretPagination } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; - -@Component({ - selector: 'app-audit-log-dialog', - templateUrl: './audit-log-dialog.component.html', - styleUrls: ['./audit-log-dialog.component.css'], - imports: [ - CommonModule, - MatDialogModule, - MatButtonModule, - MatTableModule, - MatPaginatorModule, - MatIconModule, - MatChipsModule, - MatProgressSpinnerModule, - MatTooltipModule, - ], -}) -export class AuditLogDialogComponent implements OnInit { - public logs: AuditLogEntry[] = []; - public pagination: SecretPagination = { - total: 0, - currentPage: 1, - perPage: 20, - lastPage: 1, - }; - public loading = true; - public displayedColumns = ['action', 'user', 'accessedAt', 'success']; - - public actionLabels: Record = { - create: 'Created', - view: 'Viewed', - copy: 'Copied', - update: 'Updated', - delete: 'Deleted', - }; - - public actionIcons: Record = { - create: 'add_circle', - view: 'visibility', - copy: 'content_copy', - update: 'edit', - delete: 'delete', - }; - - public actionColors: Record = { - create: 'primary', - view: 'accent', - copy: 'accent', - update: 'primary', - delete: 'warn', - }; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, - _dialogRef: MatDialogRef, - private _secrets: SecretsService, - ) {} - - ngOnInit(): void { - this.loadAuditLog(); - } - - loadAuditLog(): void { - this.loading = true; - this._secrets - .getAuditLog(this.data.secret.slug, this.pagination.currentPage, this.pagination.perPage) - .subscribe((response) => { - if (response) { - this.logs = response.data; - this.pagination = response.pagination; - } - this.loading = false; - }); - } - - onPageChange(event: PageEvent): void { - this.pagination.currentPage = event.pageIndex + 1; - this.pagination.perPage = event.pageSize; - this.loadAuditLog(); - } -} diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css deleted file mode 100644 index 9c7d52df4..000000000 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.css +++ /dev/null @@ -1,47 +0,0 @@ -.full-width { - width: 100%; - margin-bottom: 16px; -} - -.full-width:first-child { - margin-top: 12px; -} - -.master-encryption-section { - margin-top: 8px; - padding: 16px; - background-color: rgba(0, 0, 0, 0.02); - border-radius: 8px; -} - -@media (prefers-color-scheme: dark) { - .master-encryption-section { - background-color: rgba(255, 255, 255, 0.05); - } -} - -.encryption-hint { - font-size: 12px; - color: rgba(0, 0, 0, 0.54); - margin: 8px 0 16px 0; -} - -@media (prefers-color-scheme: dark) { - .encryption-hint { - color: rgba(255, 255, 255, 0.54); - } -} - -mat-dialog-content { - min-width: 400px; -} - -@media (width <= 600px) { - mat-dialog-content { - min-width: auto; - } -} - -.secret-masked { - -webkit-text-security: disc; -} diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html deleted file mode 100644 index 425b50c97..000000000 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.html +++ /dev/null @@ -1,79 +0,0 @@ -

Create Secret

- -
- - - Unique Identifier - - Use letters, numbers, hyphens, and underscores (no spaces) - {{slugError}} - - - - Secret Value - - - {{valueError}} - - - - Expiration Date (Optional) - - - - Leave empty for no expiration - - -
- - Enable master password encryption - -

- Add an extra layer of security with a master password. - You'll need this password to view or edit the secret. -

- - - Master Password - - - {{masterPasswordError}} - Remember this password - it cannot be recovered! - -
-
- - - - - -
diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts deleted file mode 100644 index 0c1fc6930..000000000 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.spec.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { of, throwError } from 'rxjs'; -import { Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { CreateSecretDialogComponent } from './create-secret-dialog.component'; - -describe('CreateSecretDialogComponent', () => { - let component: CreateSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: { createSecret: ReturnType }; - let mockDialogRef: { close: ReturnType }; - - const mockSecret: Secret = { - id: '1', - slug: 'test', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - beforeEach(async () => { - mockSecretsService = { - createSecret: vi.fn().mockReturnValue(of(mockSecret)), - }; - - mockDialogRef = { - close: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [CreateSecretDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(CreateSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('form initialization', () => { - it('should have invalid form initially', () => { - expect(component.form.invalid).toBe(true); - }); - - it('should have all required form controls', () => { - expect(component.form.get('slug')).toBeTruthy(); - expect(component.form.get('value')).toBeTruthy(); - expect(component.form.get('expiresAt')).toBeTruthy(); - expect(component.form.get('masterEncryption')).toBeTruthy(); - expect(component.form.get('masterPassword')).toBeTruthy(); - }); - - it('should initialize with default values', () => { - expect(component.form.get('slug')?.value).toBe(''); - expect(component.form.get('value')?.value).toBe(''); - expect(component.form.get('expiresAt')?.value).toBeNull(); - expect(component.form.get('masterEncryption')?.value).toBe(false); - expect(component.form.get('masterPassword')?.value).toBe(''); - }); - - it('should initialize component properties', () => { - expect(component.submitting).toBe(false); - expect(component.showValue).toBe(false); - expect(component.showMasterPassword).toBe(false); - expect(component.minDate).toBeTruthy(); - }); - }); - - describe('slug validation', () => { - it('should require slug', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue(''); - expect(slugControl?.hasError('required')).toBe(true); - }); - - it('should validate slug pattern - reject spaces', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('invalid slug'); - expect(slugControl?.hasError('pattern')).toBe(true); - }); - - it('should validate slug pattern - reject special characters', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('invalid!slug'); - expect(slugControl?.hasError('pattern')).toBe(true); - - slugControl?.setValue('invalid@slug'); - expect(slugControl?.hasError('pattern')).toBe(true); - - slugControl?.setValue('invalid#slug'); - expect(slugControl?.hasError('pattern')).toBe(true); - }); - - it('should validate slug pattern - accept valid slugs', () => { - const slugControl = component.form.get('slug'); - - slugControl?.setValue('valid-slug'); - expect(slugControl?.hasError('pattern')).toBe(false); - - slugControl?.setValue('valid_slug'); - expect(slugControl?.hasError('pattern')).toBe(false); - - slugControl?.setValue('ValidSlug123'); - expect(slugControl?.hasError('pattern')).toBe(false); - - slugControl?.setValue('valid-slug_123'); - expect(slugControl?.hasError('pattern')).toBe(false); - }); - - it('should validate max length', () => { - const slugControl = component.form.get('slug'); - slugControl?.setValue('a'.repeat(256)); - expect(slugControl?.hasError('maxlength')).toBe(true); - - slugControl?.setValue('a'.repeat(255)); - expect(slugControl?.hasError('maxlength')).toBe(false); - }); - }); - - describe('value validation', () => { - it('should require value', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue(''); - expect(valueControl?.hasError('required')).toBe(true); - }); - - it('should validate max length', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue('a'.repeat(10001)); - expect(valueControl?.hasError('maxlength')).toBe(true); - - valueControl?.setValue('a'.repeat(10000)); - expect(valueControl?.hasError('maxlength')).toBe(false); - }); - }); - - describe('master encryption', () => { - it('should require master password when encryption is enabled', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - expect(masterPasswordControl?.hasError('required')).toBe(true); - }); - - it('should validate master password min length', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - - masterPasswordControl?.setValue('short'); - expect(masterPasswordControl?.hasError('minlength')).toBe(true); - - masterPasswordControl?.setValue('12345678'); - expect(masterPasswordControl?.hasError('minlength')).toBe(false); - }); - - it('should clear master password validators when encryption is disabled', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.setValue('password123'); - - component.form.get('masterEncryption')?.setValue(false); - - const masterPasswordControl = component.form.get('masterPassword'); - expect(masterPasswordControl?.value).toBe(''); - expect(masterPasswordControl?.valid).toBe(true); - }); - - it('should accept valid master password', () => { - component.form.get('masterEncryption')?.setValue(true); - const masterPasswordControl = component.form.get('masterPassword'); - - masterPasswordControl?.setValue('validpassword123'); - expect(masterPasswordControl?.valid).toBe(true); - }); - }); - - describe('error messages', () => { - it('should return correct slug error message for required', () => { - component.form.get('slug')?.setValue(''); - component.form.get('slug')?.markAsTouched(); - expect(component.slugError).toBe('Unique identifier is required'); - }); - - it('should return correct slug error message for maxlength', () => { - component.form.get('slug')?.setValue('a'.repeat(256)); - expect(component.slugError).toBe('Unique identifier must be 255 characters or less'); - }); - - it('should return correct slug error message for pattern', () => { - component.form.get('slug')?.setValue('invalid slug!'); - expect(component.slugError).toBe('Only letters, numbers, hyphens, and underscores allowed (no spaces)'); - }); - - it('should return correct value error message for required', () => { - component.form.get('value')?.setValue(''); - component.form.get('value')?.markAsTouched(); - expect(component.valueError).toBe('Value is required'); - }); - - it('should return correct value error message for maxlength', () => { - component.form.get('value')?.setValue('a'.repeat(10001)); - expect(component.valueError).toBe('Value must be 10000 characters or less'); - }); - - it('should return correct master password error for required', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.markAsTouched(); - expect(component.masterPasswordError).toBe('Master password is required for encryption'); - }); - - it('should return correct master password error for minlength', () => { - component.form.get('masterEncryption')?.setValue(true); - component.form.get('masterPassword')?.setValue('short'); - expect(component.masterPasswordError).toBe('Master password must be at least 8 characters'); - }); - }); - - describe('visibility toggles', () => { - it('should toggle value visibility', () => { - expect(component.showValue).toBe(false); - component.toggleValueVisibility(); - expect(component.showValue).toBe(true); - component.toggleValueVisibility(); - expect(component.showValue).toBe(false); - }); - - it('should toggle master password visibility', () => { - expect(component.showMasterPassword).toBe(false); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBe(true); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBe(false); - }); - }); - - describe('form submission', () => { - it('should not submit invalid form', () => { - component.onSubmit(); - expect(mockSecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it('should submit valid form', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - expect(mockSecretsService.createSecret).toHaveBeenCalled(); - }); - - it('should submit form with correct basic payload', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith( - expect.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - }), - ); - }); - - it('should submit form with expiration date', () => { - const futureDate = new Date(); - futureDate.setFullYear(futureDate.getFullYear() + 1); - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - expiresAt: futureDate, - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith( - expect.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - expiresAt: expect.any(String), - }), - ); - }); - - it('should submit form with master encryption', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: true, - masterPassword: 'password123', - }); - - component.onSubmit(); - - expect(mockSecretsService.createSecret).toHaveBeenCalledWith( - expect.objectContaining({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: true, - masterPassword: 'password123', - }), - ); - }); - - it('should set submitting to true during submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - expect(component.submitting).toBe(false); - component.onSubmit(); - }); - - it('should close dialog on successful submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should reset submitting on successful submission', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(component.submitting).toBe(false); - }); - - it('should reset submitting on error', () => { - mockSecretsService.createSecret.mockReturnValue(throwError(() => new Error('Error'))); - - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - }); - - component.onSubmit(); - - expect(component.submitting).toBe(false); - }); - - it('should not include master password when encryption is disabled', () => { - component.form.patchValue({ - slug: 'test-secret', - value: 'secret-value', - masterEncryption: false, - }); - - component.onSubmit(); - - const calls = mockSecretsService.createSecret.mock.calls; - const callArgs = calls[calls.length - 1][0]; - expect(callArgs.masterPassword).toBeUndefined(); - }); - }); -}); diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.stories.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.stories.ts deleted file mode 100644 index a2abbbdf5..000000000 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.stories.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { of } from 'rxjs'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { CreateSecretDialogComponent } from './create-secret-dialog.component'; - -const mockSecretsService: Partial = { - createSecret: () => of(null as any), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/CreateSecret', - component: CreateSecretDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { provide: SecretsService, useValue: mockSecretsService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts b/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts deleted file mode 100644 index 3f0052e39..000000000 --- a/frontend/src/app/components/secrets/create-secret-dialog/create-secret-dialog.component.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { provideNativeDateAdapter } from '@angular/material/core'; -import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { SecretsService } from 'src/app/services/secrets.service'; - -@Component({ - selector: 'app-create-secret-dialog', - templateUrl: './create-secret-dialog.component.html', - styleUrls: ['./create-secret-dialog.component.css'], - providers: [provideNativeDateAdapter()], - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - MatDialogModule, - MatButtonModule, - MatInputModule, - MatFormFieldModule, - MatCheckboxModule, - MatDatepickerModule, - MatIconModule, - MatTooltipModule, - ], -}) -export class CreateSecretDialogComponent { - public form: FormGroup; - public submitting = false; - public showValue = false; - public showMasterPassword = false; - public minDate = new Date(); - - constructor( - private fb: FormBuilder, - private dialogRef: MatDialogRef, - private _secrets: SecretsService, - private angulartics2: Angulartics2, - ) { - this.form = this.fb.group({ - slug: ['', [Validators.required, Validators.maxLength(255), Validators.pattern(/^[a-zA-Z0-9_-]+$/)]], - value: ['', [Validators.required, Validators.maxLength(10000)]], - expiresAt: [null], - masterEncryption: [false], - masterPassword: [''], - }); - - this.form.get('masterEncryption')?.valueChanges.subscribe((enabled) => { - const masterPasswordControl = this.form.get('masterPassword'); - if (enabled) { - masterPasswordControl?.setValidators([Validators.required, Validators.minLength(8)]); - } else { - masterPasswordControl?.clearValidators(); - masterPasswordControl?.setValue(''); - } - masterPasswordControl?.updateValueAndValidity(); - }); - } - - get slugError(): string { - const control = this.form.get('slug'); - if (control?.hasError('required')) return 'Unique identifier is required'; - if (control?.hasError('maxlength')) return 'Unique identifier must be 255 characters or less'; - if (control?.hasError('pattern')) return 'Only letters, numbers, hyphens, and underscores allowed (no spaces)'; - return ''; - } - - get valueError(): string { - const control = this.form.get('value'); - if (control?.hasError('required')) return 'Value is required'; - if (control?.hasError('maxlength')) return 'Value must be 10000 characters or less'; - return ''; - } - - get masterPasswordError(): string { - const control = this.form.get('masterPassword'); - if (control?.hasError('required')) return 'Master password is required for encryption'; - if (control?.hasError('minlength')) return 'Master password must be at least 8 characters'; - return ''; - } - - toggleValueVisibility(): void { - this.showValue = !this.showValue; - } - - toggleMasterPasswordVisibility(): void { - this.showMasterPassword = !this.showMasterPassword; - } - - onSubmit(): void { - if (this.form.invalid) return; - - this.submitting = true; - const formValue = this.form.value; - - const payload = { - slug: formValue.slug, - value: formValue.value, - expiresAt: formValue.expiresAt ? new Date(formValue.expiresAt).toISOString() : undefined, - masterEncryption: formValue.masterEncryption || undefined, - masterPassword: formValue.masterEncryption ? formValue.masterPassword : undefined, - }; - - this._secrets.createSecret(payload).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Secrets: secret created successfully', - }); - posthog.capture('Secrets: secret created successfully'); - this.submitting = false; - this.dialogRef.close(true); - }, - error: () => { - this.submitting = false; - }, - }); - } -} diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css deleted file mode 100644 index 2528b138d..000000000 --- a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.css +++ /dev/null @@ -1,46 +0,0 @@ -.warning-container { - display: flex; - gap: 16px; - padding: 16px; - background-color: #fff3e0; - border-radius: 8px; -} - -@media (prefers-color-scheme: dark) { - .warning-container { - background-color: rgba(255, 152, 0, 0.15); - } -} - -.warning-icon { - font-size: 32px; - width: 32px; - height: 32px; - color: #ef6c00; - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .warning-icon { - color: #ffb74d; - } -} - -.warning-content p { - margin: 0; -} - -.warning-content p:first-child { - margin-bottom: 8px; -} - -.warning-details { - font-size: 14px; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .warning-details { - color: rgba(255, 255, 255, 0.54); - } -} diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html deleted file mode 100644 index 38bbaa7ef..000000000 --- a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.html +++ /dev/null @@ -1,23 +0,0 @@ -

Delete Secret

- - -
- warning -
-

Are you sure you want to delete the secret with identifier {{data.secret.slug}}?

-

- This action cannot be undone. The secret value and all associated audit logs will be permanently removed. -

-
-
-
- - - - - diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts deleted file mode 100644 index faa96b852..000000000 --- a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { of, throwError } from 'rxjs'; -import { DeleteSecretResponse, Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { DeleteSecretDialogComponent } from './delete-secret-dialog.component'; - -describe('DeleteSecretDialogComponent', () => { - let component: DeleteSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: { deleteSecret: ReturnType }; - let mockDialogRef: { close: ReturnType }; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - const mockSecretWithEncryption: Secret = { - ...mockSecret, - masterEncryption: true, - }; - - const mockDeleteResponse: DeleteSecretResponse = { - message: 'Secret deleted successfully', - deletedAt: '2024-01-01T00:00:00Z', - }; - - const createComponent = async (secret: Secret = mockSecret) => { - mockSecretsService = { - deleteSecret: vi.fn().mockReturnValue(of(mockDeleteResponse)), - }; - - mockDialogRef = { close: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [DeleteSecretDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { secret } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DeleteSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }; - - beforeEach(async () => { - await createComponent(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('component initialization', () => { - it('should initialize with submitting false', () => { - expect(component.submitting).toBe(false); - }); - - it('should have access to secret data', () => { - expect(component.data.secret).toEqual(mockSecret); - }); - - it('should have access to secret slug', () => { - expect(component.data.secret.slug).toBe('test-secret'); - }); - }); - - describe('onDelete', () => { - it('should call deleteSecret with correct slug', () => { - component.onDelete(); - expect(mockSecretsService.deleteSecret).toHaveBeenCalledWith('test-secret'); - }); - - it('should set submitting to true during deletion', () => { - expect(component.submitting).toBe(false); - component.onDelete(); - }); - - it('should close dialog with true after successful deletion', () => { - component.onDelete(); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should reset submitting after successful deletion', () => { - component.onDelete(); - expect(component.submitting).toBe(false); - }); - - it('should reset submitting on error', () => { - mockSecretsService.deleteSecret.mockReturnValue(throwError(() => new Error('Error'))); - - component.onDelete(); - - expect(component.submitting).toBe(false); - }); - - it('should not close dialog on error', () => { - mockSecretsService.deleteSecret.mockReturnValue(throwError(() => new Error('Error'))); - - component.onDelete(); - - expect(mockDialogRef.close).not.toHaveBeenCalled(); - }); - }); - - describe('with encrypted secret', () => { - beforeEach(async () => { - await TestBed.resetTestingModule(); - await createComponent(mockSecretWithEncryption); - }); - - it('should have access to encrypted secret data', () => { - expect(component.data.secret.masterEncryption).toBe(true); - }); - - it('should delete encrypted secret with correct slug', () => { - component.onDelete(); - expect(mockSecretsService.deleteSecret).toHaveBeenCalledWith('test-secret'); - }); - }); - - describe('dialog interactions', () => { - it('should only call deleteSecret once per click', () => { - component.onDelete(); - expect(mockSecretsService.deleteSecret).toHaveBeenCalledTimes(1); - }); - - it('should close dialog only once after successful deletion', () => { - component.onDelete(); - expect(mockDialogRef.close).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts b/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts deleted file mode 100644 index 56ac28c8f..000000000 --- a/frontend/src/app/components/secrets/delete-secret-dialog/delete-secret-dialog.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; - -@Component({ - selector: 'app-delete-secret-dialog', - templateUrl: './delete-secret-dialog.component.html', - styleUrls: ['./delete-secret-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], -}) -export class DeleteSecretDialogComponent { - public submitting = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, - private dialogRef: MatDialogRef, - private _secrets: SecretsService, - private angulartics2: Angulartics2, - ) {} - - onDelete(): void { - this.submitting = true; - this._secrets.deleteSecret(this.data.secret.slug).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Secrets: secret deleted successfully', - }); - posthog.capture('Secrets: secret deleted successfully'); - this.submitting = false; - this.dialogRef.close(true); - }, - error: () => { - this.submitting = false; - }, - }); - } -} diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css deleted file mode 100644 index 5fb251059..000000000 --- a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.css +++ /dev/null @@ -1,60 +0,0 @@ -mat-dialog-content { - min-width: 400px; - min-height: 200px; -} - -@media (width <= 600px) { - mat-dialog-content { - min-width: auto; - } -} - -.full-width { - width: 100%; - margin-bottom: 16px; -} - -.info-note { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 12px 16px; - background-color: rgba(25, 118, 210, 0.08); - border-radius: 8px; - color: #1565c0; - font-size: 14px; - margin-bottom: 20px; -} - -.info-note mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - flex-shrink: 0; -} - -@media (prefers-color-scheme: dark) { - .info-note { - background-color: rgba(100, 181, 246, 0.15); - color: #64b5f6; - } -} - -.expiration-section { - margin-bottom: 16px; -} - -.master-password-section { - margin-top: 16px; -} - -.master-password-error { - color: #f44336; - font-size: 12px; - margin-top: -12px; - margin-bottom: 8px; -} - -.secret-masked { - -webkit-text-security: disc; -} diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html deleted file mode 100644 index 48c515efb..000000000 --- a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.html +++ /dev/null @@ -1,75 +0,0 @@ -

Edit Secret: {{data.secret.slug}}

- - -
-

- info - Enter a new value for this secret. The current value cannot be viewed for security reasons. -

- - - New Secret Value - - - {{valueError}} - - -
- - Expiration Date - - - - - - - Remove expiration date - -
- -
- - Master Password - - - Required to encrypt the new value - - {{masterPasswordError}} -
-
-
- - - - - diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts deleted file mode 100644 index 4c5d2f14f..000000000 --- a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.spec.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { of, throwError } from 'rxjs'; -import { Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { EditSecretDialogComponent } from './edit-secret-dialog.component'; - -describe('EditSecretDialogComponent', () => { - let component: EditSecretDialogComponent; - let fixture: ComponentFixture; - let mockSecretsService: { updateSecret: ReturnType }; - let mockDialogRef: { close: ReturnType }; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - const mockSecretWithExpiration: Secret = { - ...mockSecret, - expiresAt: '2025-12-31T00:00:00Z', - }; - - const mockSecretWithEncryption: Secret = { - ...mockSecret, - masterEncryption: true, - }; - - const createComponent = async (secret: Secret = mockSecret) => { - mockSecretsService = { - updateSecret: vi.fn().mockReturnValue(of(secret)), - }; - - mockDialogRef = { close: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [EditSecretDialogComponent, BrowserAnimationsModule, MatSnackBarModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: MAT_DIALOG_DATA, useValue: { secret } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(EditSecretDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }; - - beforeEach(async () => { - await createComponent(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('form initialization', () => { - it('should initialize form with empty value', () => { - expect(component.form.get('value')?.value).toBe(''); - }); - - it('should have all required form controls', () => { - expect(component.form.get('value')).toBeTruthy(); - expect(component.form.get('expiresAt')).toBeTruthy(); - }); - - it('should initialize component properties', () => { - expect(component.submitting).toBe(false); - expect(component.showValue).toBe(false); - expect(component.masterPassword).toBe(''); - expect(component.masterPasswordError).toBe(''); - expect(component.showMasterPassword).toBe(false); - expect(component.clearExpiration).toBe(false); - expect(component.minDate).toBeTruthy(); - }); - - it('should initialize with null expiresAt when secret has no expiration', () => { - expect(component.form.get('expiresAt')?.value).toBeNull(); - }); - }); - - describe('form initialization with expiration', () => { - beforeEach(async () => { - await TestBed.resetTestingModule(); - await createComponent(mockSecretWithExpiration); - }); - - it('should initialize with existing expiration date', () => { - const expiresAt = component.form.get('expiresAt')?.value; - expect(expiresAt).toBeTruthy(); - expect(expiresAt instanceof Date).toBe(true); - }); - }); - - describe('value validation', () => { - it('should require value field', () => { - expect(component.form.get('value')?.valid).toBe(false); - component.form.patchValue({ value: 'some-value' }); - expect(component.form.get('value')?.valid).toBe(true); - }); - - it('should validate max length', () => { - const valueControl = component.form.get('value'); - valueControl?.setValue('a'.repeat(10001)); - expect(valueControl?.hasError('maxlength')).toBe(true); - - valueControl?.setValue('a'.repeat(10000)); - expect(valueControl?.hasError('maxlength')).toBe(false); - }); - }); - - describe('error messages', () => { - it('should return correct value error message for required', () => { - component.form.get('value')?.setValue(''); - component.form.get('value')?.markAsTouched(); - expect(component.valueError).toBe('New value is required'); - }); - - it('should return correct value error message for maxlength', () => { - component.form.get('value')?.setValue('a'.repeat(10001)); - expect(component.valueError).toBe('Value must be 10000 characters or less'); - }); - - it('should return empty string when no errors', () => { - component.form.get('value')?.setValue('valid-value'); - expect(component.valueError).toBe(''); - }); - }); - - describe('visibility toggles', () => { - it('should toggle value visibility', () => { - expect(component.showValue).toBe(false); - component.toggleValueVisibility(); - expect(component.showValue).toBe(true); - component.toggleValueVisibility(); - expect(component.showValue).toBe(false); - }); - - it('should toggle master password visibility', () => { - expect(component.showMasterPassword).toBe(false); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBe(true); - component.toggleMasterPasswordVisibility(); - expect(component.showMasterPassword).toBe(false); - }); - }); - - describe('clear expiration', () => { - it('should disable expiresAt control when clearExpiration is true', () => { - component.onClearExpirationChange(true); - - expect(component.clearExpiration).toBe(true); - expect(component.form.get('expiresAt')?.disabled).toBe(true); - }); - - it('should enable expiresAt control when clearExpiration is false', () => { - component.onClearExpirationChange(true); - component.onClearExpirationChange(false); - - expect(component.clearExpiration).toBe(false); - expect(component.form.get('expiresAt')?.enabled).toBe(true); - }); - }); - - describe('form submission', () => { - it('should not submit invalid form', () => { - component.onSubmit(); - expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); - }); - - it('should submit updated secret', () => { - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); - expect(mockSecretsService.updateSecret).toHaveBeenCalled(); - }); - - it('should submit with correct payload', () => { - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - expect.objectContaining({ value: 'new-value' }), - undefined, - ); - }); - - it('should submit with expiration date', () => { - const futureDate = new Date(); - futureDate.setFullYear(futureDate.getFullYear() + 1); - component.form.patchValue({ - value: 'new-value', - expiresAt: futureDate, - }); - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - expect.objectContaining({ - value: 'new-value', - expiresAt: expect.any(String), - }), - undefined, - ); - }); - - it('should submit with null expiration when clearExpiration is true', () => { - component.form.patchValue({ value: 'new-value' }); - component.onClearExpirationChange(true); - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - expect.objectContaining({ - value: 'new-value', - expiresAt: null, - }), - undefined, - ); - }); - - it('should set submitting to true during submission', () => { - component.form.patchValue({ value: 'new-value' }); - - expect(component.submitting).toBe(false); - component.onSubmit(); - }); - - it('should close dialog on successful submission', () => { - component.form.patchValue({ value: 'new-value' }); - - component.onSubmit(); - - expect(mockDialogRef.close).toHaveBeenCalledWith(true); - }); - - it('should reset submitting on successful submission', () => { - component.form.patchValue({ value: 'new-value' }); - - component.onSubmit(); - - expect(component.submitting).toBe(false); - }); - }); - - describe('master encryption', () => { - beforeEach(async () => { - await TestBed.resetTestingModule(); - await createComponent(mockSecretWithEncryption); - }); - - it('should require master password for encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = ''; - - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Master password is required'); - expect(mockSecretsService.updateSecret).not.toHaveBeenCalled(); - }); - - it('should submit with master password for encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'my-master-password'; - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - expect.objectContaining({ value: 'new-value' }), - 'my-master-password', - ); - }); - - it('should show error on 403 response (invalid master password)', () => { - mockSecretsService.updateSecret.mockReturnValue(throwError(() => ({ status: 403 }))); - - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'wrong-password'; - - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Invalid master password'); - expect(component.submitting).toBe(false); - }); - - it('should clear master password error on new submission attempt', () => { - component.masterPasswordError = 'Previous error'; - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = ''; - - component.onSubmit(); - - expect(component.masterPasswordError).toBe('Master password is required'); - }); - }); - - describe('non-encrypted secret submission', () => { - it('should not send master password for non-encrypted secrets', () => { - component.form.patchValue({ value: 'new-value' }); - component.masterPassword = 'some-password'; - - component.onSubmit(); - - expect(mockSecretsService.updateSecret).toHaveBeenCalledWith( - 'test-secret', - expect.objectContaining({ value: 'new-value' }), - undefined, - ); - }); - }); - - describe('error handling', () => { - it('should reset submitting on non-403 error', () => { - mockSecretsService.updateSecret.mockReturnValue(throwError(() => ({ status: 500 }))); - - component.form.patchValue({ value: 'new-value' }); - component.onSubmit(); - - expect(component.submitting).toBe(false); - }); - }); -}); diff --git a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts b/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts deleted file mode 100644 index 9cf895c5c..000000000 --- a/frontend/src/app/components/secrets/edit-secret-dialog/edit-secret-dialog.component.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { provideNativeDateAdapter } from '@angular/material/core'; -import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; - -@Component({ - selector: 'app-edit-secret-dialog', - templateUrl: './edit-secret-dialog.component.html', - styleUrls: ['./edit-secret-dialog.component.css'], - providers: [provideNativeDateAdapter()], - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - MatDialogModule, - MatButtonModule, - MatInputModule, - MatFormFieldModule, - MatDatepickerModule, - MatIconModule, - MatCheckboxModule, - MatTooltipModule, - ], -}) -export class EditSecretDialogComponent { - public form: FormGroup; - public submitting = false; - public showValue = false; - public masterPassword = ''; - public masterPasswordError = ''; - public showMasterPassword = false; - public clearExpiration = false; - public minDate = new Date(); - - constructor( - @Inject(MAT_DIALOG_DATA) public data: { secret: Secret }, - private fb: FormBuilder, - private dialogRef: MatDialogRef, - private _secrets: SecretsService, - private angulartics2: Angulartics2, - ) { - this.form = this.fb.group({ - value: ['', [Validators.required, Validators.maxLength(10000)]], - expiresAt: [data.secret.expiresAt ? new Date(data.secret.expiresAt) : null], - }); - } - - toggleValueVisibility(): void { - this.showValue = !this.showValue; - } - - toggleMasterPasswordVisibility(): void { - this.showMasterPassword = !this.showMasterPassword; - } - - get valueError(): string { - const control = this.form.get('value'); - if (control?.hasError('required')) return 'New value is required'; - if (control?.hasError('maxlength')) return 'Value must be 10000 characters or less'; - return ''; - } - - onClearExpirationChange(checked: boolean): void { - this.clearExpiration = checked; - if (checked) { - this.form.get('expiresAt')?.disable(); - } else { - this.form.get('expiresAt')?.enable(); - } - } - - onSubmit(): void { - if (this.form.invalid) return; - - if (this.data.secret.masterEncryption && !this.masterPassword) { - this.masterPasswordError = 'Master password is required'; - return; - } - - this.submitting = true; - const formValue = this.form.getRawValue(); - - const payload = { - value: formValue.value, - expiresAt: this.clearExpiration - ? null - : formValue.expiresAt - ? new Date(formValue.expiresAt).toISOString() - : undefined, - }; - - this._secrets - .updateSecret(this.data.secret.slug, payload, this.data.secret.masterEncryption ? this.masterPassword : undefined) - .subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Secrets: secret updated successfully', - }); - posthog.capture('Secrets: secret updated successfully'); - this.submitting = false; - this.dialogRef.close(true); - }, - error: (err) => { - this.submitting = false; - if (err.status === 403) { - this.masterPasswordError = 'Invalid master password'; - } - }, - }); - } -} diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css deleted file mode 100644 index f6465a85b..000000000 --- a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.css +++ /dev/null @@ -1,40 +0,0 @@ -h2[mat-dialog-title] { - display: flex; - align-items: center; - gap: 8px; -} - -.title-icon { - color: #1976d2; -} - -@media (prefers-color-scheme: dark) { - .title-icon { - color: #64b5f6; - } -} - -mat-dialog-content { - min-width: 350px; -} - -@media (width <= 600px) { - mat-dialog-content { - min-width: auto; - } -} - -.description { - color: rgba(0, 0, 0, 0.54); - margin: 0 0 24px 0; -} - -@media (prefers-color-scheme: dark) { - .description { - color: rgba(255, 255, 255, 0.54); - } -} - -.full-width { - width: 100%; -} diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html deleted file mode 100644 index d33e6be05..000000000 --- a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.html +++ /dev/null @@ -1,38 +0,0 @@ -

- enhanced_encryption - Master Password Required -

- -
- -

- This secret is protected with master password encryption. - Enter the master password to continue. -

- - - Master Password - - - {{error}} - -
- - - - - -
diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts deleted file mode 100644 index 9e293aef2..000000000 --- a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogRef } from '@angular/material/dialog'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { MasterPasswordDialogComponent } from './master-password-dialog.component'; - -describe('MasterPasswordDialogComponent', () => { - let component: MasterPasswordDialogComponent; - let fixture: ComponentFixture; - let mockDialogRef: { close: ReturnType }; - - beforeEach(async () => { - mockDialogRef = { close: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [MasterPasswordDialogComponent, BrowserAnimationsModule], - providers: [{ provide: MatDialogRef, useValue: mockDialogRef }], - }).compileComponents(); - - fixture = TestBed.createComponent(MasterPasswordDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should toggle password visibility', () => { - expect(component.showPassword).toBe(false); - component.togglePasswordVisibility(); - expect(component.showPassword).toBe(true); - }); - - it('should show error when submitting empty password', () => { - component.onSubmit(); - expect(component.error).toBe('Please enter the master password'); - }); - - it('should close dialog with password when submitting valid password', () => { - component.masterPassword = 'testpassword'; - component.onSubmit(); - expect(mockDialogRef.close).toHaveBeenCalledWith('testpassword'); - }); -}); diff --git a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts b/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts deleted file mode 100644 index f1f5adee2..000000000 --- a/frontend/src/app/components/secrets/master-password-dialog/master-password-dialog.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-master-password-dialog', - templateUrl: './master-password-dialog.component.html', - styleUrls: ['./master-password-dialog.component.css'], - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatButtonModule, - MatInputModule, - MatFormFieldModule, - MatIconModule, - MatTooltipModule, - ], -}) -export class MasterPasswordDialogComponent { - public masterPassword = ''; - public showPassword = false; - public error = ''; - - constructor(private dialogRef: MatDialogRef) {} - - togglePasswordVisibility(): void { - this.showPassword = !this.showPassword; - } - - onSubmit(): void { - if (!this.masterPassword) { - this.error = 'Please enter the master password'; - return; - } - this.dialogRef.close(this.masterPassword); - } -} diff --git a/frontend/src/app/components/secrets/secrets.component.css b/frontend/src/app/components/secrets/secrets.component.css deleted file mode 100644 index 5c90e0b62..000000000 --- a/frontend/src/app/components/secrets/secrets.component.css +++ /dev/null @@ -1,276 +0,0 @@ -.profile-layout { - display: flex; - height: calc(100vh - 64px); -} - -.profile-main { - flex: 1; - background-color: var(--mat-sidenav-content-background-color); - overflow-y: auto; -} - -::ng-deep .profile-main > app-alert { - position: relative; - top: 0; - margin: 24px; -} - -.secrets-page { - margin: var(--top-margin) auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1200px; -} - -@media (width <= 600px) { - .secrets-page { - padding: 0 16px; - margin: 1.5em auto; - } -} - -.secrets-header { - margin-bottom: 32px; -} - -.secrets-description { - color: rgba(0, 0, 0, 0.64); - margin-top: 8px; -} - -@media (prefers-color-scheme: dark) { - .secrets-description { - color: rgba(255, 255, 255, 0.7); - } -} - -.secrets-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 24px; -} - -@media (width <= 600px) { - .secrets-toolbar { - flex-direction: column; - align-items: stretch; - } -} - -.search-field { - flex: 1; - max-width: 400px; -} - -@media (width <= 600px) { - .search-field { - max-width: 100%; - } -} - -.secrets-table { - width: 100%; - margin-bottom: 16px; -} - -.secrets-cell_slug { - max-width: 250px; -} - -.slug-text { - font-weight: 500; -} - -.secrets-cell_actions { - text-align: right; -} - -.encryption-icon { - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .encryption-icon { - color: rgba(255, 255, 255, 0.7); - } -} - -.encryption-icon_master { - color: #1976d2; -} - -@media (prefers-color-scheme: dark) { - .encryption-icon_master { - color: #64b5f6; - } -} - -.no-expiry { - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-expiry { - color: rgba(255, 255, 255, 0.5); - } -} - -.expired-badge { - display: inline-flex; - align-items: center; - gap: 4px; - color: #c62828; - font-weight: 500; -} - -.expired-badge mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -@media (prefers-color-scheme: dark) { - .expired-badge { - color: #ef5350; - } -} - -.expiring-soon-badge { - display: inline-flex; - align-items: center; - gap: 4px; - color: #ef6c00; - font-weight: 500; -} - -.expiring-soon-badge mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -@media (prefers-color-scheme: dark) { - .expiring-soon-badge { - color: #ffb74d; - } -} - -.no-secrets { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 64px 24px; - text-align: center; - background-color: rgba(0, 0, 0, 0.02); - border-radius: 8px; -} - -@media (prefers-color-scheme: dark) { - .no-secrets { - background-color: rgba(255, 255, 255, 0.05); - } -} - -.no-secrets-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: rgba(0, 0, 0, 0.26); - margin-bottom: 16px; -} - -@media (prefers-color-scheme: dark) { - .no-secrets-icon { - color: rgba(255, 255, 255, 0.3); - } -} - -.no-secrets h3 { - margin: 0 0 8px 0; - color: rgba(0, 0, 0, 0.87); -} - -@media (prefers-color-scheme: dark) { - .no-secrets h3 { - color: rgba(255, 255, 255, 0.87); - } -} - -.no-secrets p { - margin: 0 0 16px 0; - color: rgba(0, 0, 0, 0.54); -} - -@media (prefers-color-scheme: dark) { - .no-secrets p { - color: rgba(255, 255, 255, 0.54); - } -} - -.delete-action { - color: #c62828; -} - -@media (prefers-color-scheme: dark) { - .delete-action { - color: #ef5350; - } -} - -/* Responsive table styles */ -@media (width <= 600px) { - .secrets-table { - display: grid; - grid-template-columns: auto 1fr; - max-width: 100%; - } - - .secrets-table-heading { - display: none; - } - - .secrets-table ::ng-deep tbody { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - } - - .secrets-row { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - grid-gap: 12px 28px; - border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); - border-bottom-width: var(--mat-table-row-item-outline-width, 1px); - border-bottom-style: solid; - height: auto; - padding: 20px 0; - } - - .secrets-cell { - display: grid; - grid-template-columns: subgrid; - grid-column: 1 / 3; - border-bottom: none; - } - - .secrets-cell::before { - content: attr(data-label); - display: inline-block; - font-weight: bold; - white-space: nowrap; - } - - .secrets-cell_actions { - grid-column: 1 / span 3; - display: flex; - justify-content: flex-end; - border-bottom: none; - } - - .secrets-cell_actions::before { - display: none; - } -} diff --git a/frontend/src/app/components/secrets/secrets.component.html b/frontend/src/app/components/secrets/secrets.component.html deleted file mode 100644 index 8177a1dcb..000000000 --- a/frontend/src/app/components/secrets/secrets.component.html +++ /dev/null @@ -1,157 +0,0 @@ -
- - -
- - -
-
-

Company Secrets

-

- Securely store and manage sensitive information like API keys, passwords, and tokens. -

-
- -
- - Search secrets - - search - - - -
- - - -
- key_off -

No secrets found

-

No secrets match your search criteria.

-

Create your first secret to securely store sensitive data.

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Unique Identifier - {{secret.slug}} - Encryption - - enhanced_encryption - - - lock - - Expires - Never - - error - Expired - - - warning - {{secret.expiresAt | date:'mediumDate'}} - - - {{secret.expiresAt | date:'mediumDate'}} - - Last Updated - {{secret.updatedAt | date:'medium'}} - - - - - - - - -
- - - -
-
-
diff --git a/frontend/src/app/components/secrets/secrets.component.spec.ts b/frontend/src/app/components/secrets/secrets.component.spec.ts deleted file mode 100644 index 83a1d5534..000000000 --- a/frontend/src/app/components/secrets/secrets.component.spec.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { PageEvent } from '@angular/material/paginator'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Title } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterModule } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { BehaviorSubject, of } from 'rxjs'; -import { Secret } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; -import { AuditLogDialogComponent } from './audit-log-dialog/audit-log-dialog.component'; -import { CreateSecretDialogComponent } from './create-secret-dialog/create-secret-dialog.component'; -import { DeleteSecretDialogComponent } from './delete-secret-dialog/delete-secret-dialog.component'; -import { EditSecretDialogComponent } from './edit-secret-dialog/edit-secret-dialog.component'; -import { SecretsComponent } from './secrets.component'; - -describe('SecretsComponent', () => { - let component: SecretsComponent; - let fixture: ComponentFixture; - let mockSecretsService: any; - let mockDialog: any; - let secretsUpdatedSubject: BehaviorSubject; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: '1', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - masterEncryption: false, - }; - - const createMockSecretsResponse = () => ({ - data: [mockSecret], - pagination: { total: 1, currentPage: 1, perPage: 20, lastPage: 1 }, - }); - - beforeEach(async () => { - secretsUpdatedSubject = new BehaviorSubject(''); - - mockSecretsService = { - fetchSecrets: vi.fn().mockImplementation(() => of(createMockSecretsResponse())), - cast: secretsUpdatedSubject.asObservable(), - } as any; - - mockDialog = { - open: vi.fn(), - } as any; - - await TestBed.configureTestingModule({ - imports: [ - SecretsComponent, - BrowserAnimationsModule, - MatSnackBarModule, - Angulartics2Module.forRoot(), - RouterModule.forRoot([]), - ], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SecretsService, useValue: mockSecretsService }, - { provide: MatDialog, useValue: mockDialog }, - ], - }) - .overrideComponent(SecretsComponent, { - remove: { imports: [ProfileSidebarComponent] }, - add: { schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(SecretsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load secrets on init', () => { - expect(mockSecretsService.fetchSecrets).toHaveBeenCalled(); - }); - - it('should set page title on init', () => { - expect(TestBed.inject(Title).getTitle()).toBe('Secrets | Rocketadmin'); - }); - - it('should initialize with pagination from response', () => { - // The pagination comes from the mock service response - expect(component.pagination.total).toBe(1); - expect(component.pagination.lastPage).toBe(1); - }); - - it('should initialize with loading true then false after load', () => { - expect(component.loading).toBe(false); - }); - - it('should have correct displayed columns', () => { - expect(component.displayedColumns).toEqual(['slug', 'masterEncryption', 'expiresAt', 'updatedAt', 'actions']); - }); - - describe('isExpired', () => { - it('should return true for expired secrets', () => { - const expiredSecret: Secret = { - ...mockSecret, - expiresAt: '2024-01-01', - }; - expect(component.isExpired(expiredSecret)).toBe(true); - }); - - it('should return false for non-expired secrets', () => { - const futureDate = new Date(); - futureDate.setFullYear(futureDate.getFullYear() + 1); - const validSecret: Secret = { - ...mockSecret, - expiresAt: futureDate.toISOString(), - }; - expect(component.isExpired(validSecret)).toBe(false); - }); - - it('should return false for secrets without expiration', () => { - expect(component.isExpired(mockSecret)).toBe(false); - }); - }); - - describe('isExpiringSoon', () => { - it('should return true for secrets expiring within 7 days', () => { - const soonDate = new Date(); - soonDate.setDate(soonDate.getDate() + 3); - const expiringSoonSecret: Secret = { - ...mockSecret, - expiresAt: soonDate.toISOString(), - }; - expect(component.isExpiringSoon(expiringSoonSecret)).toBe(true); - }); - - it('should return false for secrets expiring beyond 7 days', () => { - const laterDate = new Date(); - laterDate.setDate(laterDate.getDate() + 14); - const notExpiringSoonSecret: Secret = { - ...mockSecret, - expiresAt: laterDate.toISOString(), - }; - expect(component.isExpiringSoon(notExpiringSoonSecret)).toBe(false); - }); - - it('should return false for already expired secrets', () => { - const expiredSecret: Secret = { - ...mockSecret, - expiresAt: '2024-01-01', - }; - expect(component.isExpiringSoon(expiredSecret)).toBe(false); - }); - - it('should return false for secrets without expiration', () => { - expect(component.isExpiringSoon(mockSecret)).toBe(false); - }); - - it('should return true for secrets expiring exactly in 7 days', () => { - const exactlySevenDays = new Date(); - exactlySevenDays.setDate(exactlySevenDays.getDate() + 7); - const secret: Secret = { - ...mockSecret, - expiresAt: exactlySevenDays.toISOString(), - }; - expect(component.isExpiringSoon(secret)).toBe(true); - }); - }); - - describe('openCreateDialog', () => { - it('should open create dialog with correct configuration', () => { - component.openCreateDialog(); - expect(mockDialog.open).toHaveBeenCalledWith(CreateSecretDialogComponent, { - width: '500px', - }); - }); - }); - - describe('openEditDialog', () => { - it('should open edit dialog with secret data', () => { - component.openEditDialog(mockSecret); - expect(mockDialog.open).toHaveBeenCalledWith(EditSecretDialogComponent, { - width: '500px', - data: { secret: mockSecret }, - }); - }); - }); - - describe('openDeleteDialog', () => { - it('should open delete dialog with secret data', () => { - component.openDeleteDialog(mockSecret); - expect(mockDialog.open).toHaveBeenCalledWith(DeleteSecretDialogComponent, { - width: '400px', - data: { secret: mockSecret }, - }); - }); - }); - - describe('openAuditLogDialog', () => { - it('should open audit log dialog with secret data', () => { - component.openAuditLogDialog(mockSecret); - expect(mockDialog.open).toHaveBeenCalledWith(AuditLogDialogComponent, { - width: '800px', - maxHeight: '80vh', - data: { secret: mockSecret }, - }); - }); - }); - - describe('onPageChange', () => { - it('should update pagination and reload secrets', () => { - const pageEvent: PageEvent = { - pageIndex: 1, - pageSize: 10, - length: 100, - }; - - // Update mock to return pagination matching the page change - mockSecretsService.fetchSecrets.mockImplementation(() => - of({ - data: [mockSecret], - pagination: { total: 100, currentPage: 2, perPage: 10, lastPage: 10 }, - }), - ); - mockSecretsService.fetchSecrets.mockClear(); - component.onPageChange(pageEvent); - - expect(component.pagination.currentPage).toBe(2); - expect(component.pagination.perPage).toBe(10); - expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(2, 10, undefined); - }); - }); - - describe('onSearchChange', () => { - it('should update searchQuery on search change', () => { - component.searchQuery = ''; - component.onSearchChange('api-key'); - // Note: onSearchChange emits to a Subject, which is debounced. - // We verify the component properly processes search changes through loadSecrets - // by testing that loadSecrets uses the searchQuery parameter - }); - - it('should pass search query to loadSecrets', () => { - mockSecretsService.fetchSecrets.mockClear(); - component.searchQuery = 'test-search'; - component.loadSecrets(); - - expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, 'test-search'); - }); - - it('should reset to page 1 when loadSecrets is called with search query', () => { - component.pagination.currentPage = 3; - component.searchQuery = 'new-search'; - mockSecretsService.fetchSecrets.mockClear(); - - // Simulate what happens in the debounced subscription - component.pagination.currentPage = 1; - component.loadSecrets(); - - expect(component.pagination.currentPage).toBe(1); - expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, 'new-search'); - }); - }); - - describe('secretsUpdated subscription', () => { - it('should reload secrets when secretsUpdated emits', () => { - mockSecretsService.fetchSecrets.mockClear(); - - secretsUpdatedSubject.next('created'); - - expect(mockSecretsService.fetchSecrets).toHaveBeenCalled(); - }); - - it('should not reload secrets when secretsUpdated emits empty string', () => { - mockSecretsService.fetchSecrets.mockClear(); - - secretsUpdatedSubject.next(''); - - expect(mockSecretsService.fetchSecrets).not.toHaveBeenCalled(); - }); - }); - - describe('loadSecrets', () => { - it('should set loading to true while fetching', () => { - component.loading = false; - mockSecretsService.fetchSecrets.mockImplementation(() => of(createMockSecretsResponse())); - - component.loadSecrets(); - - expect(component.loading).toBe(false); - }); - - it('should update secrets and pagination on successful fetch', () => { - const newResponse = { - data: [mockSecret, { ...mockSecret, id: '2', slug: 'test-2' }], - pagination: { total: 2, currentPage: 1, perPage: 20, lastPage: 1 }, - }; - mockSecretsService.fetchSecrets.mockReturnValue(of(newResponse)); - - component.loadSecrets(); - - expect(component.secrets).toEqual(newResponse.data); - expect(component.pagination).toEqual(newResponse.pagination); - }); - - it('should pass search query to fetchSecrets', () => { - mockSecretsService.fetchSecrets.mockClear(); - component.searchQuery = 'api-key'; - - component.loadSecrets(); - - expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, 'api-key'); - }); - - it('should pass undefined for empty search query', () => { - mockSecretsService.fetchSecrets.mockClear(); - component.searchQuery = ''; - - component.loadSecrets(); - - expect(mockSecretsService.fetchSecrets).toHaveBeenCalledWith(1, 20, undefined); - }); - }); - - describe('ngOnDestroy', () => { - it('should unsubscribe from all subscriptions', () => { - const unsubscribeSpy = vi.spyOn(component.subscriptions[0], 'unsubscribe'); - - component.ngOnDestroy(); - - expect(unsubscribeSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/components/secrets/secrets.component.ts b/frontend/src/app/components/secrets/secrets.component.ts deleted file mode 100644 index e667d45dd..000000000 --- a/frontend/src/app/components/secrets/secrets.component.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatDialog } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; -import { MatTableModule } from '@angular/material/table'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Subject, Subscription } from 'rxjs'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; -import { Secret, SecretPagination } from 'src/app/models/secret'; -import { SecretsService } from 'src/app/services/secrets.service'; -import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; -import { PlaceholderTableDataComponent } from '../skeletons/placeholder-table-data/placeholder-table-data.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { AuditLogDialogComponent } from './audit-log-dialog/audit-log-dialog.component'; -import { CreateSecretDialogComponent } from './create-secret-dialog/create-secret-dialog.component'; -import { DeleteSecretDialogComponent } from './delete-secret-dialog/delete-secret-dialog.component'; -import { EditSecretDialogComponent } from './edit-secret-dialog/edit-secret-dialog.component'; - -@Component({ - selector: 'app-secrets', - templateUrl: './secrets.component.html', - styleUrls: ['./secrets.component.css'], - imports: [ - CommonModule, - FormsModule, - MatTableModule, - MatButtonModule, - MatIconModule, - MatMenuModule, - MatInputModule, - MatFormFieldModule, - MatPaginatorModule, - MatTooltipModule, - MatChipsModule, - MatDividerModule, - PlaceholderTableDataComponent, - AlertComponent, - ProfileSidebarComponent, - ], -}) -export class SecretsComponent implements OnInit, OnDestroy { - public secrets: Secret[] = []; - public pagination: SecretPagination = { - total: 0, - currentPage: 1, - perPage: 20, - lastPage: 1, - }; - public loading = true; - public searchQuery = ''; - public displayedColumns = ['slug', 'masterEncryption', 'expiresAt', 'updatedAt', 'actions']; - public subscriptions: Subscription[] = []; - - private searchSubject = new Subject(); - - constructor( - private _secrets: SecretsService, - private dialog: MatDialog, - private angulartics2: Angulartics2, - private title: Title, - ) {} - - ngOnInit(): void { - this.title.setTitle('Secrets | Rocketadmin'); - - this.loadSecrets(); - - const searchSub = this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe(() => { - this.pagination.currentPage = 1; - this.loadSecrets(); - }); - this.subscriptions.push(searchSub); - - const updateSub = this._secrets.cast.subscribe((action) => { - if (action) { - this.loadSecrets(); - } - }); - this.subscriptions.push(updateSub); - } - - ngOnDestroy(): void { - this.subscriptions.forEach((sub) => sub.unsubscribe()); - } - - loadSecrets(): void { - this.loading = true; - this._secrets - .fetchSecrets(this.pagination.currentPage, this.pagination.perPage, this.searchQuery || undefined) - .subscribe((response) => { - if (response) { - this.secrets = response.data; - this.pagination = response.pagination; - } - this.loading = false; - }); - } - - onSearchChange(query: string): void { - this.searchSubject.next(query); - } - - onPageChange(event: PageEvent): void { - this.pagination.currentPage = event.pageIndex + 1; - this.pagination.perPage = event.pageSize; - this.loadSecrets(); - } - - isExpired(secret: Secret): boolean { - if (!secret.expiresAt) return false; - return new Date(secret.expiresAt) < new Date(); - } - - isExpiringSoon(secret: Secret): boolean { - if (!secret.expiresAt) return false; - const expiresAt = new Date(secret.expiresAt); - const now = new Date(); - const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); - return expiresAt > now && expiresAt <= sevenDaysFromNow; - } - - openCreateDialog(): void { - this.dialog.open(CreateSecretDialogComponent, { - width: '500px', - }); - this.angulartics2.eventTrack.next({ - action: 'Secrets: create secret dialog opened', - }); - posthog.capture('Secrets: create secret dialog opened'); - } - - openEditDialog(secret: Secret): void { - this.dialog.open(EditSecretDialogComponent, { - width: '500px', - data: { secret }, - }); - this.angulartics2.eventTrack.next({ - action: 'Secrets: edit secret dialog opened', - }); - posthog.capture('Secrets: edit secret dialog opened'); - } - - openDeleteDialog(secret: Secret): void { - this.dialog.open(DeleteSecretDialogComponent, { - width: '400px', - data: { secret }, - }); - this.angulartics2.eventTrack.next({ - action: 'Secrets: delete secret dialog opened', - }); - posthog.capture('Secrets: delete secret dialog opened'); - } - - openAuditLogDialog(secret: Secret): void { - this.dialog.open(AuditLogDialogComponent, { - width: '800px', - maxHeight: '80vh', - data: { secret }, - }); - this.angulartics2.eventTrack.next({ - action: 'Secrets: audit log dialog opened', - }); - posthog.capture('Secrets: audit log dialog opened'); - } -} diff --git a/frontend/src/app/components/setup/setup.component.css b/frontend/src/app/components/setup/setup.component.css deleted file mode 100644 index c9f7df615..000000000 --- a/frontend/src/app/components/setup/setup.component.css +++ /dev/null @@ -1,117 +0,0 @@ -:host app-alert:not(:empty) { - --alert-margin: 24px; - - position: absolute; - top: var(--mat-toolbar-standard-height); - width: calc(100% - 48px); -} - -.wrapper { - height: calc(100vh - 56px); - padding: 16px; -} - -@media (width <= 600px) { - .wrapper { - padding: 0; - width: 100vw; - } -} - -.setup-page { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 100%; -} - -@media (width <= 600px) { - .setup-page { - justify-content: flex-start; - } -} - -.setup-form { - display: flex; - flex-direction: column; - align-items: center; - width: clamp(360px, 30%, 600px); -} - -@media (width <= 600px) { - .setup-form { - gap: 8px; - padding: 40px 9vw; - width: 100%; - } -} - -.setup-header { - display: flex; - flex-direction: column; - align-items: center; - padding-bottom: 40px; -} - -@media (width <= 600px) { - .setup-header { - padding-bottom: 0; - } -} - -.setup-header__logo { - margin-bottom: 36px; - width: 44px; -} - -@media (width <= 600px) { - .setup-header__logo { - margin-bottom: 12px; - } -} - -@media (prefers-color-scheme: dark) { - .setup-header__logo path { - fill: white; - } -} - -.setup-title { - font-weight: 600 !important; - margin-bottom: 40px !important; - text-align: center !important; -} - -.setup-title__emphasis { - color: var(--color-accentedPalette-500); -} - -.setup-header__directions { - text-align: center; -} - -@media (width <= 600px) { - .setup-header__directions { - display: none; - } -} - -.setup-form__email { - width: 100%; -} - -.setup-form__password { - width: 100%; -} - -.setup-form__submit-button { - width: 100%; - margin-top: 40px; -} - -@media (width <= 600px) { - .setup-form__submit-button { - margin-top: 0; - } -} diff --git a/frontend/src/app/components/setup/setup.component.html b/frontend/src/app/components/setup/setup.component.html deleted file mode 100644 index 0a2d0e105..000000000 --- a/frontend/src/app/components/setup/setup.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - -
-
-
-
- -

- Welcome to
- Rocketadmin -

-
- Create your admin account to get started. -
-
- - - - - - - -
-
-
diff --git a/frontend/src/app/components/setup/setup.component.spec.ts b/frontend/src/app/components/setup/setup.component.spec.ts deleted file mode 100644 index 031286bc4..000000000 --- a/frontend/src/app/components/setup/setup.component.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter, Router } from '@angular/router'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; -import { Angulartics2Module } from 'angulartics2'; -import { SelfhostedService } from 'src/app/services/selfhosted.service'; -import { SetupComponent } from './setup.component'; - -type SetupComponentTestable = SetupComponent & { - email: ReturnType>; - password: ReturnType>; - submitting: ReturnType>; -}; - -describe('SetupComponent', () => { - let component: SetupComponent; - let fixture: ComponentFixture; - let selfhostedService: SelfhostedService; - let router: Router; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, MatSnackBarModule, SetupComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], - providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SetupComponent); - component = fixture.componentInstance; - selfhostedService = TestBed.inject(SelfhostedService); - router = TestBed.inject(Router); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should update email signal on change', () => { - const testable = component as SetupComponentTestable; - component.onEmailChange('test@example.com'); - expect(testable.email()).toBe('test@example.com'); - }); - - it('should update password signal on change', () => { - const testable = component as SetupComponentTestable; - component.onPasswordChange('SecurePass123'); - expect(testable.password()).toBe('SecurePass123'); - }); - - it('should not submit if email is empty', async () => { - const testable = component as SetupComponentTestable; - const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); - - testable.email.set(''); - testable.password.set('SecurePass123'); - - await component.createAdminAccount(); - expect(fakeCreateInitialUser).not.toHaveBeenCalled(); - }); - - it('should not submit if password is empty', async () => { - const testable = component as SetupComponentTestable; - const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); - - testable.email.set('test@example.com'); - testable.password.set(''); - - await component.createAdminAccount(); - expect(fakeCreateInitialUser).not.toHaveBeenCalled(); - }); - - it('should create admin account and navigate to login on success', async () => { - const testable = component as SetupComponentTestable; - const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser').mockResolvedValue({ success: true }); - const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); - - testable.email.set('admin@example.com'); - testable.password.set('SecurePass123'); - - await component.createAdminAccount(); - - expect(fakeCreateInitialUser).toHaveBeenCalledWith({ - email: 'admin@example.com', - password: 'SecurePass123', - }); - expect(testable.submitting()).toBe(false); - expect(fakeNavigate).toHaveBeenCalledWith(['/login']); - }); -}); diff --git a/frontend/src/app/components/setup/setup.component.ts b/frontend/src/app/components/setup/setup.component.ts deleted file mode 100644 index 5a57a6690..000000000 --- a/frontend/src/app/components/setup/setup.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Router } from '@angular/router'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { SelfhostedService } from 'src/app/services/selfhosted.service'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; - -@Component({ - selector: 'app-setup', - templateUrl: './setup.component.html', - styleUrls: ['./setup.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - EmailValidationDirective, - AlertComponent, - UserPasswordComponent, - ], -}) -export class SetupComponent { - private _selfhostedService = inject(SelfhostedService); - private _router = inject(Router); - - protected email = signal(''); - protected password = signal(''); - protected submitting = signal(false); - - onEmailChange(value: string): void { - this.email.set(value); - } - - onPasswordChange(value: string): void { - this.password.set(value); - } - - async createAdminAccount(): Promise { - if (!this.email() || !this.password()) { - return; - } - - this.submitting.set(true); - - try { - await this._selfhostedService.createInitialUser({ - email: this.email(), - password: this.password(), - }); - this._router.navigate(['/login']); - } catch { - // Error handling is done in the service - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.css b/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.css deleted file mode 100644 index 05ad30347..000000000 --- a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.css +++ /dev/null @@ -1,35 +0,0 @@ -.wrapper { - position: relative; - z-index: 1; -} - -.form { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px 24px 20px 24px; -} - -.form__field { - height: 44px; - width: 100%; -} - -.form__string { - height: 20px; - margin-bottom: -12px; - width: 80%; -} - -.actions { - display: flex; - justify-content: flex-end; - gap: 12px; - padding: 8px; -} - -.form__button { - border-radius: 4px; - height: 36px; - width: 72px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.html b/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.html deleted file mode 100644 index 8e4b8936c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
- -
-
-
- - -
-
-
-
-
diff --git a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.spec.ts deleted file mode 100644 index 83da49f29..000000000 --- a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderAddUserDialogComponent } from './placeholder-add-user-dialog.component'; - -describe('PlaceholderAddUserDialogComponent', () => { - let component: PlaceholderAddUserDialogComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [PlaceholderAddUserDialogComponent], - }); - fixture = TestBed.createComponent(PlaceholderAddUserDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.stories.ts deleted file mode 100644 index 976c4e01a..000000000 --- a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderAddUserDialogComponent } from './placeholder-add-user-dialog.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderAddUserDialog', - component: PlaceholderAddUserDialogComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderAddUserDialogComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.ts b/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.ts deleted file mode 100644 index 35a2af67a..000000000 --- a/frontend/src/app/components/skeletons/placeholder-add-user-dialog/placeholder-add-user-dialog.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-add-user-dialog', - templateUrl: './placeholder-add-user-dialog.component.html', - styleUrls: ['./placeholder-add-user-dialog.component.css'], -}) -export class PlaceholderAddUserDialogComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.css b/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.css deleted file mode 100644 index d79ee9b69..000000000 --- a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.css +++ /dev/null @@ -1,33 +0,0 @@ -.list-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 16px; - border-radius: 8px; - margin-bottom: 0; - min-height: 40px; -} - -.list-item__info { - display: flex; - align-items: center; - gap: 12px; -} - -.list-item__icon { - height: 24px; - width: 24px; - border-radius: 4px; -} - -.list-item__title { - height: 20px; - width: 160px; - border-radius: 4px; -} - -.list-item__button { - height: 32px; - width: 32px; - border-radius: 50%; -} diff --git a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.html b/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.html deleted file mode 100644 index cae6b0e90..000000000 --- a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
-
-
-
-
-
-
-
diff --git a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.stories.ts deleted file mode 100644 index 6a07aa44e..000000000 --- a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderApiKeysListComponent } from './placeholder-api-keys-list.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderApiKeysList', - component: PlaceholderApiKeysListComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.ts b/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.ts deleted file mode 100644 index b0881de5f..000000000 --- a/frontend/src/app/components/skeletons/placeholder-api-keys-list/placeholder-api-keys-list.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NgFor } from '@angular/common'; -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-api-keys-list', - templateUrl: './placeholder-api-keys-list.component.html', - styleUrls: ['./placeholder-api-keys-list.component.css'], - imports: [NgFor], -}) -export class PlaceholderApiKeysListComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.css b/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.html b/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.html deleted file mode 100644 index efef8ec9a..000000000 --- a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.html +++ /dev/null @@ -1 +0,0 @@ -

placeholder-audit-log works!

diff --git a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.spec.ts deleted file mode 100644 index 9b4277e92..000000000 --- a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderAuditLogComponent } from './placeholder-audit-log.component'; - -describe('PlaceholderAuditLogComponent', () => { - let component: PlaceholderAuditLogComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderAuditLogComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderAuditLogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.stories.ts deleted file mode 100644 index 61c772955..000000000 --- a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderAuditLogComponent } from './placeholder-audit-log.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderAuditLog', - component: PlaceholderAuditLogComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderAuditLogComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.ts b/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.ts deleted file mode 100644 index c8c6cc4f0..000000000 --- a/frontend/src/app/components/skeletons/placeholder-audit-log/placeholder-audit-log.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-audit-log', - templateUrl: './placeholder-audit-log.component.html', - styleUrls: ['./placeholder-audit-log.component.css'], -}) -export class PlaceholderAuditLogComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.css b/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.css deleted file mode 100644 index b261d3fc1..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.css +++ /dev/null @@ -1,40 +0,0 @@ -.wrapper { - display: flex; - flex-direction: column; - justify-content: center; - height: 100%; - margin: 0 auto; - padding: 16px; - max-width: 520px; - min-width: 300px; - width: 90%; -} - -.title { - height: 32px; - margin-bottom: 32px; - width: 100%; -} - -.form__field { - height: 44px; - width: 100%; -} -.form__string { - height: 24px; - margin-bottom: 16px; - width: 120px; -} - -.form__button { - border-radius: 4px; - height: 36px; - margin-top: 16px; - margin-left: auto; - width: 64px; -} - -.form-divider { - margin-top: 48px; - margin-bottom: 20px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.html b/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.html deleted file mode 100644 index e2d4758ba..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
-
-
- -
-
- - - -
-
- -
-
-
diff --git a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.spec.ts deleted file mode 100644 index 96b897533..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderCompanyInvitationComponent } from './placeholder-company-invitation.component'; - -describe('PlaceholderCompanyInvitationComponent', () => { - let component: PlaceholderCompanyInvitationComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderCompanyInvitationComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderCompanyInvitationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.stories.ts deleted file mode 100644 index 9d59b2ae5..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderCompanyInvitationComponent } from './placeholder-company-invitation.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderCompanyInvitation', - component: PlaceholderCompanyInvitationComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.ts b/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.ts deleted file mode 100644 index 88664e6df..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company-invitation/placeholder-company-invitation.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { MatDividerModule } from '@angular/material/divider'; - -@Component({ - selector: 'app-placeholder-company-invitation', - templateUrl: './placeholder-company-invitation.component.html', - styleUrls: ['./placeholder-company-invitation.component.css'], - imports: [MatDividerModule], -}) -export class PlaceholderCompanyInvitationComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.css b/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.css deleted file mode 100644 index ca898d48d..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.css +++ /dev/null @@ -1,86 +0,0 @@ -.wrapper { - margin: var(--top-margin) auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1200px; - width: 100%; -} - -@media (width <= 600px) { - .wrapper { - padding: 0 9vw; - width: 100vw; - } -} - -.title { - height: 32px; - margin-bottom: 32px; - width: 332px; -} - -.form__field { - height: 44px; - width: 100%; -} - -.form__line { - display: flex; - justify-content: space-between; - align-items: center; - margin: 8px 0; -} - -.form__line_invite { - margin-bottom: 16px; -} - -.form__title { - height: 32px; - width: 142px; -} - -@media (width <= 600px) { - .form__title { - width: 120px; - } -} - -.form__string { - height: 24px; - width: 120px; -} - -@media (width <= 600px) { - .form__string { - width: 80px; - } -} - -.form__button { - border-radius: 4px; - height: 36px; -} - -.form__button_plan { - width: 190px; -} - -@media (width <= 600px) { - .form__button_plan { - width: 124px; - } -} - -.form__button_invite { - width: 152px; -} - -@media (width <= 600px) { - .form__button_plan { - width: 132px; - } -} - -.form__table { - padding: 0 8px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.html b/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.html deleted file mode 100644 index 14c84f4f6..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-
-
- -
-
-
-
- -
-
-
-
- -
- -
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.spec.ts deleted file mode 100644 index 91e4042e4..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderCompanyComponent } from './placeholder-company.component'; - -describe('PlaceholderCompanyComponent', () => { - let component: PlaceholderCompanyComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderCompanyComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderCompanyComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.stories.ts deleted file mode 100644 index 413aee59d..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderCompanyComponent } from './placeholder-company.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderCompany', - component: PlaceholderCompanyComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.ts b/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.ts deleted file mode 100644 index 2a82d08b4..000000000 --- a/frontend/src/app/components/skeletons/placeholder-company/placeholder-company.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { PlaceholderTableDataComponent } from '../placeholder-table-data/placeholder-table-data.component'; - -@Component({ - selector: 'app-placeholder-company', - templateUrl: './placeholder-company.component.html', - styleUrls: ['./placeholder-company.component.css'], - imports: [CommonModule, PlaceholderTableDataComponent], -}) -export class PlaceholderCompanyComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.css b/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.css deleted file mode 100644 index 742d3a166..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.css +++ /dev/null @@ -1,59 +0,0 @@ -.form { - display: grid; - grid-template-columns: 1fr 1fr; - grid-gap: 20px; - margin: 0 auto 40px; - width: 100%; -} - -.form__line { - display: flex; - align-items: center; - gap: 20px; -} - -.form__field { - height: 44px; - width: 100%; -} - -.form__field_full { - grid-column: 1 / span 2; -} - -.form__logo { - flex-shrink: 0; - border-radius: 50%; - height: 24px; - width: 24px; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - background-color: var(--mat-sidenav-content-background-color); - box-shadow: - 0 3px 1px -2px rgba(0, 0, 0, 0.2), - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 1px 5px 0 rgba(0, 0, 0, 0.12); - height: 64px; - padding: 0 max(calc(50vw - 325px), 2%); - width: 100vw; -} - -.actions__button { - border-radius: 4px; - height: 36px; - width: 72px; - mix-blend-mode: normal !important; - z-index: 2; -} - -.actions__button_left { - margin-right: auto; -} diff --git a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.html b/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.html deleted file mode 100644 index d06107795..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
-
- -
-
- -
- - -
-
- - -
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.spec.ts deleted file mode 100644 index dcb376086..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderConnectionSettingsComponent } from './placeholder-connection-settings.component'; - -describe('PlaceholderConnectionSettingsComponent', () => { - let component: PlaceholderConnectionSettingsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderConnectionSettingsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderConnectionSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.stories.ts deleted file mode 100644 index d0be16f07..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderConnectionSettingsComponent } from './placeholder-connection-settings.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderConnectionSettings', - component: PlaceholderConnectionSettingsComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderConnectionSettingsComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.ts b/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.ts deleted file mode 100644 index 7689177e8..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connection-settings/placeholder-connection-settings.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-connection-settings', - templateUrl: './placeholder-connection-settings.component.html', - styleUrls: ['./placeholder-connection-settings.component.css'], -}) -export class PlaceholderConnectionSettingsComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.css b/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.css deleted file mode 100644 index a5d4843b8..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.css +++ /dev/null @@ -1,36 +0,0 @@ -.connections { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 20px; - width: 100%; -} - -@media (width <= 600px) { - .connections { - flex-direction: column; - } -} - -.connectionCard { - flex: 0 0 calc((100% - 40px) / 3); - border-radius: 4px; - height: 148px; - width: 100%; -} - -@media (width <= 600px) { - .connectionCard { - flex: initial; - } -} - -.connectionAddButton { - align-self: flex-end; - border-radius: 24px; - height: 48px; - margin-top: 36px; - margin-bottom: 32px; - margin-left: auto; - width: 178px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.html b/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.html deleted file mode 100644 index fc592b412..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
-
-
-
-
- -
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.spec.ts deleted file mode 100644 index b09e13402..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderConnectionsComponent } from './placeholder-connections.component'; - -describe('PlaceholderConnectionsComponent', () => { - let component: PlaceholderConnectionsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderConnectionsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderConnectionsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.stories.ts deleted file mode 100644 index 99e09da62..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderConnectionsComponent } from './placeholder-connections.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderConnections', - component: PlaceholderConnectionsComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderConnectionsComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.ts b/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.ts deleted file mode 100644 index eaf94b7bc..000000000 --- a/frontend/src/app/components/skeletons/placeholder-connections/placeholder-connections.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-connections', - templateUrl: './placeholder-connections.component.html', - styleUrls: ['./placeholder-connections.component.css'], -}) -export class PlaceholderConnectionsComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.css b/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.css deleted file mode 100644 index 95792456a..000000000 --- a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.css +++ /dev/null @@ -1,58 +0,0 @@ -.dashboards-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 24px; -} - -@media (width <= 600px) { - .dashboards-grid { - grid-template-columns: 1fr; - gap: 16px; - } -} - -.dashboard-card { - border-radius: 4px; - overflow: hidden; -} - -.card-preview { - height: 160px; -} - -.card-content { - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.card-title { - height: 20px; - width: 60%; - border-radius: 4px; -} - -.card-description { - height: 16px; - width: 80%; - border-radius: 4px; -} - -.card-meta { - display: flex; - justify-content: space-between; - margin-top: 4px; -} - -.card-meta-left { - height: 14px; - width: 80px; - border-radius: 4px; -} - -.card-meta-right { - height: 14px; - width: 60px; - border-radius: 4px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.html b/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.html deleted file mode 100644 index ddca61138..000000000 --- a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.html +++ /dev/null @@ -1,70 +0,0 @@ -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.stories.ts deleted file mode 100644 index 5247cfd83..000000000 --- a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderDashboardsComponent } from './placeholder-dashboards.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderDashboards', - component: PlaceholderDashboardsComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderDashboardsComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.ts b/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.ts deleted file mode 100644 index 585f95e4b..000000000 --- a/frontend/src/app/components/skeletons/placeholder-dashboards/placeholder-dashboards.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-dashboards', - templateUrl: './placeholder-dashboards.component.html', - styleUrls: ['./placeholder-dashboards.component.css'], -}) -export class PlaceholderDashboardsComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.css b/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.css deleted file mode 100644 index 8f17fb8be..000000000 --- a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.css +++ /dev/null @@ -1,42 +0,0 @@ -.loading-related-records { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px 0 16px; -} - -.loading-related-records__title { - mix-blend-mode: normal !important; - height: 28px; - width: 120px; -} - -.loading-related-records__button { - mix-blend-mode: normal !important; - border-radius: 50%; - height: 36px; - width: 36px; -} - -.record-field { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 16px; -} - -.record-field:not(:last-child) { - border-bottom: solid 1px rgba(0, 0, 0, 0.12); -} - -.record-field__key { - mix-blend-mode: normal !important; - height: 20px; - width: 120px; -} - -.record-field__value { - mix-blend-mode: normal !important; - height: 20px; - width: 200px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.html b/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.html deleted file mode 100644 index c45399893..000000000 --- a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.html +++ /dev/null @@ -1,28 +0,0 @@ -
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.spec.ts deleted file mode 100644 index 6347aeb4c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderRecordViewComponent } from './placeholder-record-view.component'; - -describe('PlaceholderRecordViewComponent', () => { - let component: PlaceholderRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderRecordViewComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.stories.ts deleted file mode 100644 index 499f41d6c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderRecordViewComponent } from './placeholder-record-view.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderRecordView', - component: PlaceholderRecordViewComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.ts b/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.ts deleted file mode 100644 index 346edfe96..000000000 --- a/frontend/src/app/components/skeletons/placeholder-record-view/placeholder-record-view.component.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-record-view', - imports: [], - templateUrl: './placeholder-record-view.component.html', - styleUrl: './placeholder-record-view.component.css', -}) -export class PlaceholderRecordViewComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.css b/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.css deleted file mode 100644 index ef863add1..000000000 --- a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.css +++ /dev/null @@ -1,74 +0,0 @@ -.wrapper { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - padding: 36px 0 16px; -} - -.row-edit-header { - display: flex; - align-items: center; -} - -.row-edit-header__breadcrumb { - border-radius: 4px; - height: 36px; -} - -.row-edit-header__breadcrumb_1 { - width: 120px; -} - -.row-edit-header__breadcrumb_2 { - width: 172px; -} - -.row-edit-header__breadcrumb_3 { - width: 80px; -} - -.form { - display: flex; - flex-direction: column; - gap: 16px; - margin: 28px auto 40px; - max-width: 520px; - min-width: 300px; - width: 96%; -} - -.form__field { - height: 44px; - width: 100%; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - background-color: var(--mat-sidenav-content-background-color); - box-shadow: - 0 3px 1px -2px rgba(0, 0, 0, 0.2), - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 1px 5px 0 rgba(0, 0, 0, 0.12); - height: 64px; - padding: 0 max(calc(50vw - 260px), 2%); - width: 100vw; -} - -.actions__button { - border-radius: 4px; - height: 36px; - width: 80px; - mix-blend-mode: normal !important; - z-index: 2; -} - -.actions__button_left { - margin-right: auto; -} diff --git a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.html b/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.html deleted file mode 100644 index 90bd13cf8..000000000 --- a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
-
- chevron_right -
- chevron_right -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.spec.ts deleted file mode 100644 index 7769133e6..000000000 --- a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderRowEditComponent } from './placeholder-row-edit.component'; - -describe('PlaceholderRowEditComponent', () => { - let component: PlaceholderRowEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderRowEditComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderRowEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.stories.ts deleted file mode 100644 index c453f9f36..000000000 --- a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderRowEditComponent } from './placeholder-row-edit.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderRowEdit', - component: PlaceholderRowEditComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.ts b/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.ts deleted file mode 100644 index 0c0cd17f8..000000000 --- a/frontend/src/app/components/skeletons/placeholder-row-edit/placeholder-row-edit.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; - -@Component({ - selector: 'app-placeholder-row-edit', - templateUrl: './placeholder-row-edit.component.html', - styleUrls: ['./placeholder-row-edit.component.css'], - imports: [MatIconModule, CommonModule], -}) -export class PlaceholderRowEditComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.css b/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.css deleted file mode 100644 index 277ae786b..000000000 --- a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.css +++ /dev/null @@ -1,31 +0,0 @@ -.box { - display: flex; - align-items: center; - gap: 8px; - width: 100%; -} - -.edit-button { - height: 36px; - width: 36px; - border-radius: 50%; - margin-right: 8px; - /* mix-blend-mode: normal !important; */ -} - -.saved-filter { - height: 32px; - border-radius: 4px; -} - -.saved-filter_1 { - width: 100px; -} - -.saved-filter_2 { - width: 150px; -} - -.saved-filter_3 { - width: 200px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.html b/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.html deleted file mode 100644 index 397079f46..000000000 --- a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.spec.ts deleted file mode 100644 index ee3ef5da3..000000000 --- a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderSavedFiltersComponent } from './placeholder-saved-filters.component'; - -describe('PlaceholderSavedFiltersComponent', () => { - let component: PlaceholderSavedFiltersComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderSavedFiltersComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderSavedFiltersComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.stories.ts deleted file mode 100644 index 9b589bd42..000000000 --- a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderSavedFiltersComponent } from './placeholder-saved-filters.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderSavedFilters', - component: PlaceholderSavedFiltersComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderSavedFiltersComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.ts b/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.ts deleted file mode 100644 index 73ef7f401..000000000 --- a/frontend/src/app/components/skeletons/placeholder-saved-filters/placeholder-saved-filters.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-saved-filters', - templateUrl: './placeholder-saved-filters.component.html', - styleUrls: ['./placeholder-saved-filters.component.css'], -}) -export class PlaceholderSavedFiltersComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.css b/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.css deleted file mode 100644 index 1b2301f0d..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.css +++ /dev/null @@ -1,72 +0,0 @@ -.data-table { - display: grid; - grid-template-columns: 56px 20% 30% 7% 8% 15% calc(20% - 56px); - overflow: auto; - width: 100%; -} - -.table-cell { - border-bottom: 1px solid #0000001f; - box-sizing: border-box; - padding: 8px 16px; -} - -.table-cell-content { - height: 24px; -} - -.data-table_mobile { - display: none; -} - -@media (width <= 600px) { - .data-table_desktop { - display: none; - } - - :host { - display: block; - box-shadow: none !important; - } - - .data-table_mobile { - display: flex; - flex-direction: column; - gap: 12px; - padding: 0 0 16px; - } - - .mobile-card { - background: var(--mat-table-background-color, #fff); - border: none; - border-radius: 12px; - padding: 12px 16px; - display: flex; - flex-direction: column; - gap: 8px; - } - - .mobile-card__value { - width: 100%; - height: 14px; - border-radius: 4px; - } - - .mobile-card__value_short { - width: 30%; - } - - .mobile-card__value_medium { - width: 60%; - } - - .mobile-card__value_long { - width: 80%; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .mobile-card { - background: var(--surface-dark-color); - } -} diff --git a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.html b/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.html deleted file mode 100644 index 20d8b6c0f..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
-
-
- -
-
-
-
-
-
-
-
diff --git a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.spec.ts deleted file mode 100644 index cdd2d655c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderTableDataComponent } from './placeholder-table-data.component'; - -describe('PlaceholderTableDataComponent', () => { - let component: PlaceholderTableDataComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderTableDataComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderTableDataComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.stories.ts deleted file mode 100644 index 8a3674bf3..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderTableDataComponent } from './placeholder-table-data.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderTableData', - component: PlaceholderTableDataComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.ts b/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.ts deleted file mode 100644 index a15ac8a0d..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-data/placeholder-table-data.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NgForOf } from '@angular/common'; -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-table-data', - templateUrl: './placeholder-table-data.component.html', - styleUrls: ['./placeholder-table-data.component.css'], - imports: [NgForOf], -}) -export class PlaceholderTableDataComponent { - public numberOfDivs = Array.from({ length: 42 }, (_, index) => index); - public numberOfCards = Array.from({ length: 6 }, (_, index) => index); -} diff --git a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.css b/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.css deleted file mode 100644 index e3e75d2da..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.css +++ /dev/null @@ -1,129 +0,0 @@ -.wrapper { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - padding: 36px 0 16px; -} - -@media (width <= 600px) { - .wrapper { - padding: 36px 9vw 16px; - } -} - -.row-edit-header { - display: flex; - align-items: center; -} - -.row-edit-header__breadcrumb { - border-radius: 4px; - height: 36px; -} - -.row-edit-header__breadcrumb_1 { - width: 120px; -} - -@media (width <= 600px) { - .row-edit-header__breadcrumb_1 { - width: 60px; - } -} - -.row-edit-header__breadcrumb_2 { - width: 172px; -} - -@media (width <= 600px) { - .row-edit-header__breadcrumb_2 { - width: 120px; - } -} - -.row-edit-header__breadcrumb_3 { - width: 80px; -} - -@media (width <= 600px) { - .row-edit-header__breadcrumb_3 { - width: 52px; - } -} - -.form { - display: flex; - flex-direction: column; - margin: 28px auto 40px; - padding: 0 max(calc(50vw - 325px), 10%); - width: 100%; -} - -@media (width <= 600px) { - .form { - padding: 0; - } -} - -.form__heading { - height: 32px; - margin-top: 20px; - margin-bottom: -4px; - width: 142px; -} - -.form__line { - display: flex; - align-items: center; - gap: 12px; -} - -.form__field { - height: 44px; - margin-top: 20px; - width: 100%; -} - -.form__info { - height: 16px; - margin-top: 4px; - width: 63%; -} - -.form__addButton { - border-radius: 4px; - height: 36px; - margin-bottom: -20px; - width: 72px; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - background-color: var(--mat-sidenav-content-background-color); - box-shadow: - 0 3px 1px -2px rgba(0, 0, 0, 0.2), - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 1px 5px 0 rgba(0, 0, 0, 0.12); - height: 64px; - padding: 0 max(calc(50vw - 325px), 2%); - width: 100vw; -} - -.actions__button { - border-radius: 4px; - height: 36px; - width: 72px; - mix-blend-mode: normal !important; - z-index: 2; -} - -.actions__button_left { - margin-right: auto; -} diff --git a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.html b/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.html deleted file mode 100644 index 85f0b00aa..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.html +++ /dev/null @@ -1,52 +0,0 @@ -
-
-
- chevron_right -
- chevron_right -
-
-
-
- -
-
-
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.spec.ts deleted file mode 100644 index 7eba8eb98..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderTableSettingsComponent } from './placeholder-table-settings.component'; - -describe('PlaceholderTableSettingsComponent', () => { - let component: PlaceholderTableSettingsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderTableSettingsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderTableSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.stories.ts deleted file mode 100644 index 7c101aec4..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderTableSettingsComponent } from './placeholder-table-settings.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderTableSettings', - component: PlaceholderTableSettingsComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.ts b/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.ts deleted file mode 100644 index 3ce2dcb75..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-settings/placeholder-table-settings.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; - -@Component({ - selector: 'app-placeholder-table-settings', - templateUrl: './placeholder-table-settings.component.html', - styleUrls: ['./placeholder-table-settings.component.css'], - imports: [CommonModule, MatIconModule], -}) -export class PlaceholderTableSettingsComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.css b/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.css deleted file mode 100644 index 2e215346c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.css +++ /dev/null @@ -1,48 +0,0 @@ -.placeholder { - display: flex; - height: 100%; -} - -.sidebar { - border-right: solid 1px rgba(0, 0, 0, 0.12); - height: 100%; - max-width: 240px; - width: 14%; -} - -.table-header { - display: flex; - margin-top: 8px; - padding: 12px 0; - width: 100%; -} - -.table-header__title { - margin-right: auto; - height: 36px; - width: 15vw; -} - -.table-header__search { - border-radius: 4px 4px 0 0; - height: 36px; - width: 200px; -} - -.table-header__button { - border-radius: 4px; - margin-left: 12px; - height: 36px; - width: 80px; -} - -.table-header__icon-button { - border-radius: 50%; - margin-left: 12px; - height: 36px; - width: 36px; -} - -.table-view { - padding: 0 24px 24px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.html b/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.html deleted file mode 100644 index 5eea4908c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
- -
-
-
- -
-
-
-
-
- -
-
diff --git a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.spec.ts deleted file mode 100644 index 6a50ae7e9..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderTableViewComponent } from './placeholder-table-view.component'; - -describe('PlaceholderTableViewComponent', () => { - let component: PlaceholderTableViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderTableViewComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderTableViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.stories.ts deleted file mode 100644 index 1d1c639e0..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderTableViewComponent } from './placeholder-table-view.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderTableView', - component: PlaceholderTableViewComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.ts b/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.ts deleted file mode 100644 index 903103b96..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-view/placeholder-table-view.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { PlaceholderTableDataComponent } from '../placeholder-table-data/placeholder-table-data.component'; - -@Component({ - selector: 'app-placeholder-table-view', - templateUrl: './placeholder-table-view.component.html', - styleUrls: ['./placeholder-table-view.component.css'], - imports: [CommonModule, PlaceholderTableDataComponent], -}) -export class PlaceholderTableViewComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.css b/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.css deleted file mode 100644 index 053eab550..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.css +++ /dev/null @@ -1,78 +0,0 @@ -.wrapper { - position: relative; - min-width: 300px; - margin: 0 auto 16px; - width: 90%; -} - -.row-edit-header { - display: flex; - align-items: flex-start; - margin-top: 36px; - margin-bottom: 28px; -} - -.row-edit-header__breadcrumb { - border-radius: 4px; - height: 36px; -} - -.row-edit-header__breadcrumb_1 { - width: 120px; -} - -.row-edit-header__breadcrumb_2 { - width: 172px; -} - -.row-edit-header__breadcrumb_3 { - width: 80px; -} - -.widgets { - display: grid; - grid-template-columns: minmax(10%, 130px) 1fr 2fr 1fr 2fr 50px; - align-items: start; - grid-gap: 16px 20px; -} - -.form__field { - height: 44px; - width: 100%; -} - -.form__info { - border-radius: 50%; - height: 24px; - width: 24px; -} - -.actions { - position: fixed; - left: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - background-color: var(--mat-sidenav-content-background-color); - box-shadow: - 0 3px 1px -2px rgba(0, 0, 0, 0.2), - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 1px 5px 0 rgba(0, 0, 0, 0.12); - height: 64px; - padding: 0 calc(5% + 70px) 0 5%; - width: 100vw; -} - -.actions__button { - border-radius: 4px; - height: 36px; - width: 80px; - mix-blend-mode: normal !important; - z-index: 2; -} - -.actions__button_left { - margin-right: auto; -} diff --git a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.html b/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.html deleted file mode 100644 index 84ed8bcea..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.html +++ /dev/null @@ -1,28 +0,0 @@ -
-
-
- chevron_right -
- chevron_right -
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.spec.ts deleted file mode 100644 index 0a0317b87..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderTableWidgetsComponent } from './placeholder-table-widgets.component'; - -describe('PlaceholderTableWidgetsComponent', () => { - let component: PlaceholderTableWidgetsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderTableWidgetsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderTableWidgetsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.stories.ts deleted file mode 100644 index f60855c30..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.stories.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PlaceholderTableWidgetsComponent } from './placeholder-table-widgets.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderTableWidgets', - component: PlaceholderTableWidgetsComponent, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.ts b/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.ts deleted file mode 100644 index 2456f7ba6..000000000 --- a/frontend/src/app/components/skeletons/placeholder-table-widgets/placeholder-table-widgets.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; - -@Component({ - selector: 'app-placeholder-table-widgets', - templateUrl: './placeholder-table-widgets.component.html', - styleUrls: ['./placeholder-table-widgets.component.css'], - imports: [MatIconModule], -}) -export class PlaceholderTableWidgetsComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.css b/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.css deleted file mode 100644 index 36f867e11..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.css +++ /dev/null @@ -1,13 +0,0 @@ -.box { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 20px; - width: 100%; -} - -.user-email { - height: 20px; - width: 100%; - mix-blend-mode: normal !important; -} diff --git a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.html b/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.html deleted file mode 100644 index d6acfb530..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.spec.ts deleted file mode 100644 index 156607785..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderUserGroupComponent } from './placeholder-user-group.component'; - -describe('PlaceholderUserGroupComponent', () => { - let component: PlaceholderUserGroupComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderUserGroupComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderUserGroupComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.stories.ts deleted file mode 100644 index 3b28165c0..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderUserGroupComponent } from './placeholder-user-group.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderUserGroup', - component: PlaceholderUserGroupComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderUserGroupComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.ts b/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.ts deleted file mode 100644 index f47547f98..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-group/placeholder-user-group.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-user-group', - templateUrl: './placeholder-user-group.component.html', - styleUrls: ['./placeholder-user-group.component.css'], -}) -export class PlaceholderUserGroupComponent {} diff --git a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.css b/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.css deleted file mode 100644 index 3d739c96c..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.css +++ /dev/null @@ -1,28 +0,0 @@ -.panels { - border-radius: 4px; - box-shadow: - 0px 3px 1px -2px rgba(0, 0, 0, 0.2), - 0px 2px 2px 0px rgba(0, 0, 0, 0.14), - 0px 1px 5px 0px rgba(0, 0, 0, 0.12); - margin-top: 16px; -} - -.panel { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid #0000001f; - height: 36px; - padding: 0 24px; -} - -.groupName { - height: 20px; - width: 240px; -} - -.groupAction { - border-radius: 50%; - height: 24px; - width: 24px; -} diff --git a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.html b/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.html deleted file mode 100644 index a0dd5c13a..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.spec.ts b/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.spec.ts deleted file mode 100644 index ac44b2417..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { PlaceholderUserGroupsComponent } from './placeholder-user-groups.component'; - -describe('PlaceholderUserGroupsComponent', () => { - let component: PlaceholderUserGroupsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PlaceholderUserGroupsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PlaceholderUserGroupsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.stories.ts b/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.stories.ts deleted file mode 100644 index e12910087..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { PlaceholderUserGroupsComponent } from './placeholder-user-groups.component'; - -const meta: Meta = { - title: 'Skeletons/PlaceholderUserGroups', - component: PlaceholderUserGroupsComponent, - decorators: [ - moduleMetadata({ - declarations: [PlaceholderUserGroupsComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.ts b/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.ts deleted file mode 100644 index cb658b118..000000000 --- a/frontend/src/app/components/skeletons/placeholder-user-groups/placeholder-user-groups.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-placeholder-user-groups', - templateUrl: './placeholder-user-groups.component.html', - styleUrls: ['./placeholder-user-groups.component.css'], -}) -export class PlaceholderUserGroupsComponent {} diff --git a/frontend/src/app/components/ui-components/alert/alert.component.css b/frontend/src/app/components/ui-components/alert/alert.component.css deleted file mode 100644 index 745bb8eda..000000000 --- a/frontend/src/app/components/ui-components/alert/alert.component.css +++ /dev/null @@ -1,104 +0,0 @@ -:host { - position: sticky; - top: calc(var(--mat-toolbar-standard-height) + 24px); - display: block; - margin: var(--alert-margin); - z-index: 2; -} - -.alert { - display: flex; - gap: 8px; - background-color: var(--bg-color); - backdrop-filter: blur(4px); - border-top: 1px solid var(--alert-theme-color); - border-right: 1px solid var(--alert-theme-color); - border-bottom: 1px solid var(--alert-theme-color); - border-left: 12px solid var(--alert-theme-color); - border-radius: 4px; - padding: 12px 16px 12px 8px; -} - -@media (prefers-color-scheme: dark) { - .alert { - backdrop-filter: blur(2px); - } -} - -@media (width <= 600px) { - .alert { - display: grid; - grid-template-columns: auto 1fr; - } -} - -.alert_error { - --alert-theme-color: var(--color-warnPalette-500); - --bg-color: var(--error-background-color); -} - -.alert_warning { - --alert-theme-color: var(--color-warningPalette-500); - --bg-color: var(--warning-background-color); -} - -.alert_info { - --alert-theme-color: var(--color-infoPalette-500); - --bg-color: var(--info-background-color); -} - -.alert_success { - --alert-theme-color: var(--color-successPalette-500); - --bg-color: var(--success-background-color); -} - -.alert__icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - color: var(--alert-theme-color); - font-size: 24px; - height: 36px; - opacity: 0.9; - width: 36px; -} - -.alert__message { - flex: 1; - display: flex; - justify-content: center; - flex-direction: column; - min-height: 36px; - margin: 0 !important; -} - -.alert__title { - color: var(--alert-theme-color); - margin-bottom: 8px; -} - -@media (prefers-color-scheme: dark) { - .alert__title { - color: inherit; - } -} - -.alert__text { - margin: 0 !important; -} - -.actions { - margin-left: auto; -} - -@media (width <= 600px) { - .actions { - grid-column: 1 / -1; - } -} - -.mat-flat-button { - color: var(--text-color); -} diff --git a/frontend/src/app/components/ui-components/alert/alert.component.html b/frontend/src/app/components/ui-components/alert/alert.component.html deleted file mode 100644 index e9113c84d..000000000 --- a/frontend/src/app/components/ui-components/alert/alert.component.html +++ /dev/null @@ -1,34 +0,0 @@ -
- {{icons[currentAlert.type]}} -
- {{messageAbstract}} -

{{messageDetails}}

-
-

- {{currentAlert.message}} -

-
- -
-
- - - - {{action.caption}} - - - {{action.caption}} - - -
-
diff --git a/frontend/src/app/components/ui-components/alert/alert.component.spec.ts b/frontend/src/app/components/ui-components/alert/alert.component.spec.ts deleted file mode 100644 index df35bb32a..000000000 --- a/frontend/src/app/components/ui-components/alert/alert.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; -import { AlertComponent } from './alert.component'; - -describe('AlertComponent', () => { - let component: AlertComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, AlertComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AlertComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should call finction from action on alert button click', () => { - const buttonAction = vi.fn(); - const alert = { - id: 0, - type: AlertType.Error, - message: 'Error message', - actions: [ - { - type: AlertActionType.Button, - caption: 'Dissmis', - action: buttonAction, - }, - ], - }; - component.onButtonClick(alert, alert.actions[0]); - expect(buttonAction).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/app/components/ui-components/alert/alert.component.ts b/frontend/src/app/components/ui-components/alert/alert.component.ts deleted file mode 100644 index 1d75e8c26..000000000 --- a/frontend/src/app/components/ui-components/alert/alert.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; -import { Alert } from 'src/app/models/alert'; -import { NotificationsService } from 'src/app/services/notifications.service'; - -@Component({ - selector: 'app-alert', - templateUrl: './alert.component.html', - styleUrls: ['./alert.component.css'], - imports: [CommonModule, MatIconModule, MatButtonModule, RouterModule], -}) -export class AlertComponent implements OnInit { - @Input() alert: Alert; - - public messageAbstract: string; - public messageDetails: string; - - public icons = { - error: 'error_outline', - warning: 'warning_amber', - info: 'info', - success: 'task_alt', - }; - - constructor(private _notifications: NotificationsService) {} - - get currentAlert(): Alert { - return this.alert || this._notifications.currentAlert; - } - - ngOnInit(): void { - console.log(this.currentAlert); - } - - onButtonClick(alert: Alert, action) { - action.action(alert.id); - } - - getMessageType() { - if (this.currentAlert.message === null) return; - if (typeof this.currentAlert.message === 'string') return 'plainMessage'; - if (this.currentAlert.message.abstract && !this.currentAlert.message.details) { - this.currentAlert.message = this.currentAlert.message.abstract; - return 'plainMessage'; - } - if (this.currentAlert.message.details) { - this.messageAbstract = this.currentAlert.message.abstract; - this.messageDetails = this.currentAlert.message.details; - return 'complexMessage'; - } - } -} diff --git a/frontend/src/app/components/ui-components/alert/alert.stories.ts b/frontend/src/app/components/ui-components/alert/alert.stories.ts deleted file mode 100644 index 194d2d241..000000000 --- a/frontend/src/app/components/ui-components/alert/alert.stories.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { MAT_ICON_DEFAULT_OPTIONS, MatIconDefaultOptions } from '@angular/material/icon'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { AlertType } from 'src/app/models/alert'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { AlertComponent } from './alert.component'; - -const meta: Meta = { - title: 'UI/Alert', - component: AlertComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: NotificationsService, useValue: { currentAlert: null } }, - { - provide: MAT_ICON_DEFAULT_OPTIONS, - useValue: { fontSet: 'material-symbols-outlined' } as MatIconDefaultOptions, - }, - ], - }), - ], - args: { - alert: { id: 1, type: AlertType.Info, message: 'This is an informational alert.' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Info: Story = { - args: { alert: { id: 1, type: AlertType.Info, message: 'This is an informational message.' } }, -}; - -export const Warning: Story = { - args: { alert: { id: 2, type: AlertType.Warning, message: 'This is a warning message.' } }, -}; - -export const ErrorAlert: Story = { - args: { alert: { id: 3, type: AlertType.Error, message: 'An error has occurred.' } }, -}; - -export const Success: Story = { - args: { alert: { id: 4, type: AlertType.Success, message: 'Operation completed successfully.' } }, -}; - -export const ComplexMessage: Story = { - args: { - alert: { - id: 5, - type: AlertType.Error, - message: { - abstract: 'Connection failed', - details: 'Could not connect to the database. Please check your credentials and try again.', - }, - }, - }, -}; diff --git a/frontend/src/app/components/ui-components/banner/banner.component.css b/frontend/src/app/components/ui-components/banner/banner.component.css deleted file mode 100644 index 9fc8bbc75..000000000 --- a/frontend/src/app/components/ui-components/banner/banner.component.css +++ /dev/null @@ -1,70 +0,0 @@ -:host { - display: block; - height: 100%; -} - -.banner-wrapper { - display: flex; - align-items: center; - justify-content: center; - background-position: center, center; - background-repeat: no-repeat, no-repeat; - background-size: 100%, cover; - height: calc(100% + 1px); - margin-top: -1px; -} - -@media (prefers-color-scheme: light) { - .banner-wrapper { - background-image: - linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), url("../../../../assets/bg/banner.svg"); - } -} - -@media (prefers-color-scheme: dark) { - .banner-wrapper { - background-image: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url("../../../../assets/bg/banner.svg"); - background-color: #1a1a1a; - } -} - -.banner_error { - --alert-theme-color: var(--color-warnPalette-500); - --bg-color: var(--error-background-color); -} - -.banner_warning { - --alert-theme-color: var(--color-warningPalette-500); - --bg-color: var(--warning-background-color); -} - -.banner_info { - --alert-theme-color: var(--color-infoPalette-500); - --bg-color: var(--info-background-color); -} - -.banner_success { - --alert-theme-color: var(--color-successPalette-500); - --bg-color: var(--success-background-color); -} - -.banner-box { - background-color: var(--bg-color); - backdrop-filter: blur(2px); - border: 1px solid var(--alert-theme-color); - border-left: 12px solid var(--alert-theme-color); - border-radius: 4px; - box-shadow: - 0px 1px 5px 0px rgba(0, 0, 0, 0.2), - 0px 3px 4px 0px rgba(0, 0, 0, 0.12), - 0px 2px 4px 0px rgba(0, 0, 0, 0.14); - padding: 24px 20px 24px 28px; - width: clamp(300px, 50vw, 450px); -} - -.banner-box ::ng-deep .error-actions { - display: flex; - justify-content: flex-end; - gap: 20px; - margin-top: 32px; -} diff --git a/frontend/src/app/components/ui-components/banner/banner.component.html b/frontend/src/app/components/ui-components/banner/banner.component.html deleted file mode 100644 index 501068cc6..000000000 --- a/frontend/src/app/components/ui-components/banner/banner.component.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/frontend/src/app/components/ui-components/banner/banner.component.spec.ts b/frontend/src/app/components/ui-components/banner/banner.component.spec.ts deleted file mode 100644 index 528240c6f..000000000 --- a/frontend/src/app/components/ui-components/banner/banner.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BannerComponent } from './banner.component'; - -describe('BannerComponent', () => { - let component: BannerComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, BannerComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BannerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/banner/banner.component.ts b/frontend/src/app/components/ui-components/banner/banner.component.ts deleted file mode 100644 index d604f269a..000000000 --- a/frontend/src/app/components/ui-components/banner/banner.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component, input } from '@angular/core'; - -import { AlertType } from 'src/app/models/alert'; - -@Component({ - selector: 'app-banner', - templateUrl: './banner.component.html', - styleUrls: ['./banner.component.css'], -}) -export class BannerComponent { - type = input(); -} diff --git a/frontend/src/app/components/ui-components/banner/banner.stories.ts b/frontend/src/app/components/ui-components/banner/banner.stories.ts deleted file mode 100644 index fd34ed570..000000000 --- a/frontend/src/app/components/ui-components/banner/banner.stories.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { AlertType } from 'src/app/models/alert'; -import { BannerComponent } from './banner.component'; - -const meta: Meta = { - title: 'UI/Banner', - component: BannerComponent, - decorators: [ - moduleMetadata({ - declarations: [BannerComponent], - }), - ], - args: { - type: AlertType.Info, - }, - argTypes: { - type: { - control: 'select', - options: [AlertType.Info, AlertType.Warning, AlertType.Error, AlertType.Success], - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Info: Story = { args: { type: AlertType.Info } }; -export const Warning: Story = { args: { type: AlertType.Warning } }; -export const ErrorBanner: Story = { args: { type: AlertType.Error } }; -export const Success: Story = { args: { type: AlertType.Success } }; diff --git a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.css b/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.css deleted file mode 100644 index 8115c7757..000000000 --- a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.breadcrumbs { - display: flex; - align-items: center; -} - -@media (prefers-color-scheme: li) { - .breadcrumb, - .breadcrumbDivider { - color: rgba(0, 0, 0, 0.6); - } -} - -.static-crumb { - margin-left: 8px; - margin-right: 16px; - margin-bottom: 0 !important; -} diff --git a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.html b/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.html deleted file mode 100644 index 29bd56e91..000000000 --- a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.html +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.spec.ts b/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.spec.ts deleted file mode 100644 index 17ea00679..000000000 --- a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BreadcrumbsComponent } from './breadcrumbs.component'; - -describe('BreadcrumbsComponent', () => { - let component: BreadcrumbsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BreadcrumbsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BreadcrumbsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.ts b/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.ts deleted file mode 100644 index f0d694cd3..000000000 --- a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, input } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; - -interface Breadcrumb { - label: string; - link?: string | null; - queryParams?: Record; -} - -@Component({ - selector: 'app-breadcrumbs', - templateUrl: './breadcrumbs.component.html', - styleUrls: ['./breadcrumbs.component.css'], - standalone: true, - imports: [RouterModule, MatIconModule, MatButtonModule], -}) -export class BreadcrumbsComponent { - crumbs = input([]); -} diff --git a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.stories.ts b/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.stories.ts deleted file mode 100644 index 4d3b89f75..000000000 --- a/frontend/src/app/components/ui-components/breadcrumbs/breadcrumbs.stories.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BreadcrumbsComponent } from './breadcrumbs.component'; - -const meta: Meta = { - title: 'UI/Breadcrumbs', - component: BreadcrumbsComponent, - args: { - crumbs: [ - { label: 'Home', link: '/' }, - { label: 'Connections', link: '/connections' }, - { label: 'PostgreSQL', link: '' }, - ], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const SingleCrumb: Story = { - args: { crumbs: [{ label: 'Home', link: '/' }] }, -}; - -export const LongPath: Story = { - args: { - crumbs: [ - { label: 'Home', link: '/' }, - { label: 'Connections', link: '/connections' }, - { label: 'PostgreSQL', link: '/connections/pg' }, - { label: 'users', link: '/connections/pg/users' }, - { label: 'Record #42', link: '' }, - ], - }, -}; diff --git a/frontend/src/app/components/ui-components/content-loader/content-loader.component.css b/frontend/src/app/components/ui-components/content-loader/content-loader.component.css deleted file mode 100644 index dbfac9255..000000000 --- a/frontend/src/app/components/ui-components/content-loader/content-loader.component.css +++ /dev/null @@ -1,26 +0,0 @@ -.dots { - width: 20px; - height: 20px; - background: #171d39; - border-radius: 50%; - position: absolute; - top: calc(50% - 20px); - left: calc(50% - 20px); - box-shadow: - #171d39 20px 0, - #171d39 40px 0, - #171d39 -20px 0, - #171d39 -40px 0; - animation: pulse 1.25s infinite ease-in-out; -} - -@keyframes pulse { - 50% { - box-shadow: - #171d39 45px 0, - #171d39 90px 0, - #171d39 -45px 0, - #171d39 -90px 0; - transform: scale(0.7); - } -} diff --git a/frontend/src/app/components/ui-components/content-loader/content-loader.component.html b/frontend/src/app/components/ui-components/content-loader/content-loader.component.html deleted file mode 100644 index bdad0715c..000000000 --- a/frontend/src/app/components/ui-components/content-loader/content-loader.component.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/frontend/src/app/components/ui-components/content-loader/content-loader.component.spec.ts b/frontend/src/app/components/ui-components/content-loader/content-loader.component.spec.ts deleted file mode 100644 index c675d059c..000000000 --- a/frontend/src/app/components/ui-components/content-loader/content-loader.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ContentLoaderComponent } from './content-loader.component'; - -describe('ContentLoaderComponent', () => { - let component: ContentLoaderComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ContentLoaderComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ContentLoaderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/content-loader/content-loader.component.ts b/frontend/src/app/components/ui-components/content-loader/content-loader.component.ts deleted file mode 100644 index f92b0ae93..000000000 --- a/frontend/src/app/components/ui-components/content-loader/content-loader.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -@Component({ - selector: 'app-content-loader', - templateUrl: './content-loader.component.html', - styleUrls: ['./content-loader.component.css'], -}) -export class ContentLoaderComponent implements OnInit { - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/ui-components/content-loader/content-loader.stories.ts b/frontend/src/app/components/ui-components/content-loader/content-loader.stories.ts deleted file mode 100644 index 72e4f94ad..000000000 --- a/frontend/src/app/components/ui-components/content-loader/content-loader.stories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { moduleMetadata } from '@storybook/angular'; -import { ContentLoaderComponent } from './content-loader.component'; - -const meta: Meta = { - title: 'UI/ContentLoader', - component: ContentLoaderComponent, - decorators: [ - moduleMetadata({ - declarations: [ContentLoaderComponent], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md b/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md deleted file mode 100644 index 4e10e6b6f..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/FILTER_WIDGETS.md +++ /dev/null @@ -1,257 +0,0 @@ -# Filter Widget Architecture - -## Directory - -All filter components live under `filter-fields/`. Each has: `*.component.ts`, `*.component.html`, `*.component.css`, `*.component.spec.ts`. - -## Base Class - -`base-filter-field/base-filter-field.component.ts` - -Signal-based inputs and outputs every filter inherits: - -**Inputs:** `key`, `label`, `required`, `readonly`, `structure` (TableField), `disabled`, `widgetStructure`, `relations` (TableForeignKey), `autofocus` - -**Outputs:** `onFieldChange` (emits filter value), `onComparatorChange` (emits comparator string) - -**Computed:** `normalizedLabel` — human-readable field name - -## Two Types of Filter Components - -### 1. Simple filters (dialog manages comparator) - -Set `static type` to control which comparator dropdown the dialog shows: - -- `static type = 'text'` → dialog shows: startswith, endswith, eq, contains, icontains, empty -- `static type = 'number'` or `'datetime'` → dialog shows: eq, gt, lt, gte, lte -- No `static type` (or undefined) → `nonComparable`, dialog shows NO comparator - -The component only emits `onFieldChange`. The dialog renders its own `` for the comparator. - -**Examples:** `TextFilterComponent`, `NumberFilterComponent`, `DateFilterComponent` - -### 2. Smart filters (component manages its own comparator) - -Do NOT set `static type` — this makes `getComparatorType()` return `'nonComparable'`, so the dialog renders only the component with no external comparator dropdown. - -The component has an internal `filterMode` property, renders its own `` for mode selection, and emits BOTH `onFieldChange` (value) and `onComparatorChange` (comparator) to the dialog. - -**Examples:** `EmailFilterComponent`, `PhoneFilterComponent`, `DateTimeFilterComponent` - -## Comparator Routing Logic - -In `db-table-filters-dialog.component.ts` (and `saved-filters-panel`): - -``` -getInputType(field) → reads ComponentClass.type (static property) -getComparatorType(type): - 'text' → dialog shows text comparators - 'number'/'datetime' → dialog shows number comparators - anything else → 'nonComparable' → dialog shows nothing, component manages itself -``` - -## Registration - -### In `consts/filter-types.ts`: - -- `UIwidgets` object: maps widget type names → component classes (e.g., `DateTime: DateTimeFilterComponent`) -- `filterTypes` object: maps database column types → component classes per database (e.g., `postgres['timestamp without time zone']: DateTimeFilterComponent`) - -### In `db-table-filters-dialog`: - -- `UIwidgets` from `filter-types.ts` merged with `record-edit-types.ts` -- Widget-based fields use `UIwidgets[widget_type]` -- Database-type fields use `filterTypes[connectionType][columnType]` - -## Data Flow - -1. User adds filter → dialog creates entry in `tableRowFieldsShown[field]` and `tableRowFieldsComparator[field]` (default: `'eq'`) -2. `ndc-dynamic` renders the component with inputs (`value`, `key`, `label`, etc.) and output handlers (`onFieldChange` → `updateField`, `onComparatorChange` → `updateComparatorFromComponent`) -3. Component emits value/comparator → dialog stores them -4. User clicks "Filter" → dialog closes → filters encoded as JsonURL in URL query params → table refetches - -## Filter Data Format - -URL: `?filters=` where filters = `{ fieldName: { comparator: value } }` - -Example: `{ created_at: { gte: "2024-01-01T00:00:00Z" }, email: { endswith: "@gmail.com" } }` - ---- - -# How to Create a New Filter Widget - -## Step 1: Create Component Files - -Create directory: `filter-fields//` - -### TypeScript (`.component.ts`) - -```typescript -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; // only for smart filters -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-', - templateUrl: './.component.html', - styleUrls: ['./.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], -}) -export class FilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: any; - @ViewChild('inputElement') inputElement: ElementRef; - - // For SIMPLE filter: set static type - // static type = 'text'; // or 'number' or 'datetime' - - // For SMART filter: do NOT set static type, add filterMode instead - public filterMode: string = 'eq'; - - ngOnInit(): void { - // Parse this.value if restoring from URL - } - - ngAfterViewInit(): void { - // Emit initial comparator (smart filters only) - this.onComparatorChange.emit(this.filterMode); - - // Autofocus support - if (this.autofocus() && this.inputElement) { - setTimeout(() => this.inputElement.nativeElement.focus(), 100); - } - } - - // Smart filter: handle mode changes - onFilterModeChange(mode: string): void { - this.filterMode = mode; - this.onComparatorChange.emit(mode); // or map to backend comparator - this.onFieldChange.emit(this.value); - } - - // Handle value changes - onValueChange(val: any): void { - this.value = val; - this.onFieldChange.emit(this.value); - this.onComparatorChange.emit(this.filterMode); // smart filter only - } -} -``` - -### Template (`.component.html`) - -**Simple filter** — just a value input: - -```html - - {{normalizedLabel()}} - - -``` - -**Smart filter** — mode selector + conditional input: - -```html -
- - - equal - - - - - @if (filterMode !== 'some_special_mode') { - - {{normalizedLabel()}} - - - } -
-``` - -### CSS (`.component.css`) - -**Smart filter layout** (reuse across all smart filters): - -```css -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} -.value-field { - flex: 1; -} -``` - -## Step 2: Register the Component - -In `consts/filter-types.ts`: - -1. Import the component -2. Add to `UIwidgets` object if it's a custom widget type: - ```typescript - export const UIwidgets = { - // ...existing... - MyWidget: MyWidgetFilterComponent, - }; - ``` -3. Add to `filterTypes` database mappings if it maps to a database column type: - ```typescript - postgres: { - my_column_type: MyWidgetFilterComponent, - }, - ``` - -## Step 3: Write Tests - -```typescript -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('FilterComponent', () => { - let component: FilterComponent; - let fixture: ComponentFixture<FilterComponent>; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - // Test value emission - // Test comparator emission (smart filters) - // Test URL restoration (ngOnInit with existing value) - // Test mode switching (smart filters) -}); -``` - -**Note:** Tests use Vitest (`vi.spyOn`), not Jasmine. Use `toBe(true)` not `toBeTrue()`. - -## Key Gotchas - -- **Do NOT override `onFieldChange` or `onComparatorChange`** — the base class outputs work fine. Overriding creates a shadowed emitter. -- **Comparator mapping**: internal filter modes can differ from backend comparators. Email's `'domain'` mode emits `'endswith'`; phone's `'country'` mode emits `'startswith'`. -- **URL restoration**: when restoring from URL, you can't distinguish preset modes from custom ones — default to showing as a custom comparator. -- **Timezone**: for datetime values, use `toISOString()` for genuine UTC. Never use `format()` with literal `'Z'` suffix — it outputs local time with a fake UTC marker. -- **`fixture.detectChanges()`**: move it out of `beforeEach` if your component emits in `ngAfterViewInit` — call it only in tests that need the full lifecycle. diff --git a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.css b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.html b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.html deleted file mode 100644 index 441615276..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.html +++ /dev/null @@ -1 +0,0 @@ -

base-filter-field works!

diff --git a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.spec.ts deleted file mode 100644 index 41fef9d85..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BaseFilterFieldComponent } from './base-filter-field.component'; - -describe('BaseFilterFieldComponent', () => { - let component: BaseFilterFieldComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BaseFilterFieldComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BaseFilterFieldComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts b/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts deleted file mode 100644 index 3bf9db36d..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/base-filter-field/base-filter-field.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Component, computed, input, OnInit, output } from '@angular/core'; -import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; -import { normalizeFieldName } from '../../../../lib/normalize'; - -@Component({ - selector: 'app-base-filter-field', - templateUrl: './base-filter-field.component.html', - styleUrl: './base-filter-field.component.css', -}) -export class BaseFilterFieldComponent implements OnInit { - readonly key = input(); - readonly label = input(); - readonly required = input(false); - readonly readonly = input(false); - readonly structure = input(); - readonly disabled = input(false); - readonly widgetStructure = input(); - readonly relations = input(); - readonly autofocus = input(false); - - readonly onFieldChange = output(); - readonly onComparatorChange = output(); - - readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.css b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html deleted file mode 100644 index ae4e7562d..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {{normalizedLabel()}} - - binary data - -
diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.spec.ts deleted file mode 100644 index 5e0a23f9e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BinaryDataCaptionFilterComponent } from './binary-data-caption.component'; - -describe('BinaryDataCaptionFilterComponent', () => { - let component: BinaryDataCaptionFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryDataCaptionFilterComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BinaryDataCaptionFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.stories.ts deleted file mode 100644 index 9a30db879..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BinaryDataCaptionFilterComponent } from './binary-data-caption.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'bytea', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/BinaryDataCaption', - component: BinaryDataCaptionFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.ts b/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.ts deleted file mode 100644 index 7c6385778..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary-data-caption/binary-data-caption.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-static-text', - templateUrl: './binary-data-caption.component.html', - styleUrls: ['./binary-data-caption.component.css'], - imports: [CommonModule], -}) -export class BinaryDataCaptionFilterComponent extends BaseFilterFieldComponent {} diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css deleted file mode 100644 index 3b1f34518..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.css +++ /dev/null @@ -1,15 +0,0 @@ -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} - -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} - -.value-field { - flex: 1; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html deleted file mode 100644 index fa15842bd..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.html +++ /dev/null @@ -1,50 +0,0 @@ -
- - - equal - contains - starts with - is empty - - - - @if (filterMode !== 'empty') { - - {{normalizedLabel()}} ({{ encoding() }}) - @switch (encoding()) { - @case ('hex') { - - @if (binaryInput.errors?.isInvalidHex) { - Invalid hex. - } - } - @case ('base64') { - - @if (binaryInput.errors?.isInvalidBase64) { - Invalid base64. - } - } - @case ('ascii') { - - } - } - - } -
diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts deleted file mode 100644 index f32a7453a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BinaryFilterComponent } from './binary.component'; - -describe('BinaryFilterComponent', () => { - let component: BinaryFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(BinaryFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('defaults to eq filter mode with empty input', () => { - fixture.detectChanges(); - expect(component.filterMode).toBe('eq'); - expect(component.rawInput).toBe(''); - }); - - it('normalizes the incoming hex value through bytes on init', () => { - component.value = '48656c6c6f'; - component.ngOnInit(); - expect(component.rawInput).toBe('48656c6c6f'); - }); - - it('drops a malformed incoming hex value to empty on init', () => { - component.value = 'zz'; - component.ngOnInit(); - expect(component.rawInput).toBe(''); - }); - - it('emits the hex string and current comparator on input change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.onInputChange('abcdef'); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef'); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('switches to empty mode and clears input', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.rawInput = 'abcdef'; - component.onFilterModeChange('empty'); - - expect(component.rawInput).toBe(''); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); - }); - - it('emits contains comparator and re-emits current hex', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.rawInput = 'abcdef'; - component.onFilterModeChange('contains'); - - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('contains'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('abcdef'); - }); - - it('emits startswith comparator', () => { - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.onFilterModeChange('startswith'); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('startswith'); - }); - - describe('encoding param', () => { - it('seeds rawInput in the selected encoding from the incoming hex value', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - component.value = '48656c6c6f'; - component.ngOnInit(); - expect(component.encoding()).toBe('base64'); - expect(component.rawInput).toBe('SGVsbG8='); - }); - - it('emits hex to backend when the user types base64', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - vi.spyOn(component.onFieldChange, 'emit'); - fixture.detectChanges(); - - component.onInputChange('SGVsbG8='); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); - expect(component.isInvalidInput).toBe(false); - }); - - it('emits empty hex and marks invalid when base64 input is malformed', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - vi.spyOn(component.onFieldChange, 'emit'); - fixture.detectChanges(); - - component.onInputChange('!!!'); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); - expect(component.isInvalidInput).toBe(true); - }); - - it('emits hex to backend when the user types ascii', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); - vi.spyOn(component.onFieldChange, 'emit'); - fixture.detectChanges(); - - component.onInputChange('Hi'); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith('4869'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts deleted file mode 100644 index 203d8b722..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/binary/binary.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { AfterViewInit, Component, computed, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Base64ValidationDirective } from 'src/app/directives/base64Validator.directive'; -import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; -import { bytesToEncoded, bytesToHex, encodedToBytes, hexStringToBytes, isBinaryEncoding } from 'src/app/lib/binary'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -export type BinaryFilterMode = 'eq' | 'contains' | 'startswith' | 'empty'; - -@Component({ - selector: 'app-filter-binary', - templateUrl: './binary.component.html', - styleUrls: ['./binary.component.css'], - imports: [ - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - HexValidationDirective, - Base64ValidationDirective, - ], -}) -export class BinaryFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - public filterMode: BinaryFilterMode = 'eq'; - public rawInput = ''; - public isInvalidInput = false; - - public readonly encoding = computed(() => { - const raw = this.widgetStructure()?.widget_params?.encoding; - return isBinaryEncoding(raw) ? raw : 'hex'; - }); - - override ngOnInit(): void { - const incomingBytes = hexStringToBytes(this.value ?? ''); - this.rawInput = bytesToEncoded(incomingBytes, this.encoding()); - } - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => this.inputElement.nativeElement.focus(), 100); - } - } - - onFilterModeChange(mode: BinaryFilterMode): void { - this.filterMode = mode; - if (mode === 'empty') { - this.rawInput = ''; - this.isInvalidInput = false; - this.onComparatorChange.emit('empty'); - this.onFieldChange.emit(''); - return; - } - this.onComparatorChange.emit(mode); - this.onFieldChange.emit(this.currentHex()); - } - - onInputChange(value: string): void { - this.rawInput = value; - this.onFieldChange.emit(this.currentHex()); - this.onComparatorChange.emit(this.filterMode); - } - - private currentHex(): string { - if (!this.rawInput) { - this.isInvalidInput = false; - return ''; - } - const bytes = encodedToBytes(this.rawInput, this.encoding()); - if (bytes === null) { - this.isInvalidInput = true; - return ''; - } - this.isInvalidInput = false; - return bytesToHex(bytes); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.css b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.css deleted file mode 100644 index ef61e81a4..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.radio-line { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; -} - -.radio-line ::ng-deep .mat-button-toggle-appearance-standard.mat-button-toggle-checked { - --mat-button-toggle-selected-state-text-color: var(--color-accentedPalette-500-contrast); - --mat-button-toggle-selected-state-background-color: var(--color-accentedPalette-500); -} diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html deleted file mode 100644 index 60c23eb0c..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
- - - Yes - No - @if (isRadiogroup) { - Null - } - -
diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts deleted file mode 100644 index 7dfd1f4f0..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BooleanFilterComponent } from './boolean.component'; - -describe('BooleanFilterComponent', () => { - let component: BooleanFilterComponent; - let fixture: ComponentFixture; - - const fakeStructureNotNull = { - column_name: 'banned', - column_default: '0', - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 1, - }; - - const fakeStructureNullable = { - ...fakeStructureNotNull, - allow_null: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, BooleanFilterComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BooleanFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set booleanValue to false when input value is 0', () => { - component.value = 0; - fixture.componentRef.setInput('structure', fakeStructureNotNull); - component.ngOnInit(); - - expect(component.booleanValue).toEqual(false); - }); - - it('should set booleanValue to unknown when input value is null', () => { - component.value = null; - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - expect(component.booleanValue).toEqual('unknown'); - }); - - it('should set booleanValue to unknown when input value is empty string', () => { - component.value = ''; - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - expect(component.booleanValue).toEqual('unknown'); - }); - - it('should set isRadiogroup to false if allow_null is false', () => { - component.value = undefined; - fixture.componentRef.setInput('structure', fakeStructureNotNull); - component.ngOnInit(); - - expect(component.isRadiogroup).toEqual(false); - }); - - it('should set isRadiogroup to true if allow_null is true', () => { - component.value = undefined; - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - expect(component.isRadiogroup).toEqual(true); - }); - - it('should emit eq comparator when Yes is toggled', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - component.booleanValue = true; - component.onBooleanChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalled(); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should emit eq comparator when No is toggled', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - component.booleanValue = false; - component.onBooleanChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalled(); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should emit eq comparator and null value when Null is toggled', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', fakeStructureNullable); - component.ngOnInit(); - - component.booleanValue = 'unknown'; - component.onBooleanChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should render Null option when allow_null is true', () => { - fixture.componentRef.setInput('structure', fakeStructureNullable); - fixture.detectChanges(); - - const buttons = fixture.nativeElement.querySelectorAll('mat-button-toggle'); - const labels = Array.from(buttons).map((b: Element) => b.textContent?.trim()); - - expect(labels).toContain('Null'); - }); - - it('should not render Null option when allow_null is false', () => { - fixture.componentRef.setInput('structure', fakeStructureNotNull); - fixture.detectChanges(); - - const buttons = fixture.nativeElement.querySelectorAll('mat-button-toggle'); - const labels = Array.from(buttons).map((b: Element) => b.textContent?.trim()); - - expect(labels).not.toContain('Null'); - expect(labels).toContain('Yes'); - expect(labels).toContain('No'); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.stories.ts deleted file mode 100644 index 72818eb8a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.stories.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BooleanFilterComponent } from './boolean.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'boolean', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Boolean', - component: BooleanFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: true, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const True: Story = { args: { value: true } }; -export const False: Story = { args: { value: false } }; -export const Null: Story = { args: { value: null } }; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts deleted file mode 100644 index 8ba6c5a30..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/boolean/boolean.component.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { DBtype } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-boolean', - templateUrl: './boolean.component.html', - styleUrls: ['./boolean.component.css'], - imports: [CommonModule, FormsModule, MatButtonToggleModule], -}) -export class BooleanFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value; - - public isRadiogroup: boolean; - public booleanValue: boolean | 'unknown'; - private connectionType: DBtype; - - constructor(private _connections: ConnectionsService) { - super(); - } - - ngOnInit(): void { - this.connectionType = this._connections.currentConnection.type; - this.setBooleanValue(); - - let parsedParams = null; - const ws = this.widgetStructure(); - if (ws?.widget_params) { - parsedParams = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - } - - this.isRadiogroup = this.structure()?.allow_null || !!parsedParams?.allow_null; - } - - ngAfterViewInit(): void { - this.onComparatorChange.emit('eq'); - } - - setBooleanValue() { - if (typeof this.value === 'boolean') { - this.booleanValue = this.value; - return; - } - - if (this.value === null || this.value === undefined || this.value === '') { - this.booleanValue = 'unknown'; - return; - } - - switch (this.value) { - case 0: - case '0': - case 'F': - case 'N': - case 'false': - this.booleanValue = false; - break; - case 1: - case '1': - case 'T': - case 'Y': - case 'true': - this.booleanValue = true; - break; - } - } - - onBooleanChange() { - let formattedValue; - switch (this.connectionType) { - case DBtype.MySQL: - case DBtype.MSSQL: - case DBtype.Oracle: - formattedValue = this.booleanValue === 'unknown' ? null : Number(this.booleanValue); - break; - default: - formattedValue = this.booleanValue === 'unknown' ? null : this.booleanValue; - } - - this.onFieldChange.emit(formattedValue); - this.onComparatorChange.emit('eq'); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.css b/frontend/src/app/components/ui-components/filter-fields/country/country.component.css deleted file mode 100644 index 163ee20ea..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.css +++ /dev/null @@ -1,23 +0,0 @@ -.country-form-field { - width: 100%; -} - -.country-flag { - margin-right: 8px; - font-size: 16px; -} - -.country-name { - flex: 1; -} - -.country-code { - margin-left: 8px; - opacity: 0.7; - font-size: 12px; -} - -mat-option { - display: flex; - align-items: center; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.html b/frontend/src/app/components/ui-components/filter-fields/country/country.component.html deleted file mode 100644 index cbb2222cb..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.html +++ /dev/null @@ -1,17 +0,0 @@ - - {{normalizedLabel()}} - - - - {{country.flag}} - {{country.label}} - ({{country.value}}) - - - diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/country/country.component.stories.ts deleted file mode 100644 index f772dd00e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CountryFilterComponent } from './country.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Country', - component: CountryFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'US', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts b/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts deleted file mode 100644 index 03c44b41f..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/country/country.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Observable } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -interface CountryOption { - value: string | null; - label: string; - flag: string; -} - -const BASE_COUNTRIES: CountryOption[] = COUNTRIES.map((country) => ({ - value: country.code, - label: country.name, - flag: getCountryFlag(country.code), -})); - -@Component({ - selector: 'app-filter-country', - templateUrl: './country.component.html', - styleUrls: ['./country.component.css'], - imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class CountryFilterComponent extends BaseFilterFieldComponent { - @Input() value: string; - - public countries: CountryOption[] = []; - public countryControl = new FormControl(''); - public filteredCountries: Observable; - - originalOrder = () => { - return 0; - }; - - getCountryFlag = getCountryFlag; - - ngOnInit(): void { - const ws = this.widgetStructure(); - const struct = this.structure(); - if (ws?.widget_params?.allow_null || struct?.allow_null) { - this.countries = [{ value: null, label: '', flag: '' }, ...BASE_COUNTRIES]; - } else { - this.countries = BASE_COUNTRIES; - } - - this.setupAutocomplete(); - this.setInitialValue(); - } - - onCountrySelected(selectedCountry: CountryOption): void { - this.value = selectedCountry.value; - this.onFieldChange.emit(this.value); - } - - displayFn(country: any): string { - return country ? country.label : ''; - } - - private setupAutocomplete(): void { - this.filteredCountries = this.countryControl.valueChanges.pipe( - startWith(''), - map((value) => this._filter(typeof value === 'string' ? value : value?.label || '')), - ); - } - - private setInitialValue(): void { - if (this.value) { - const country = this.countries.find((c) => c.value === this.value); - if (country) { - this.countryControl.setValue(country); - } - } - } - - private _filter(value: string): CountryOption[] { - const filterValue = value.toLowerCase(); - return this.countries.filter( - (country) => - country.label?.toLowerCase().includes(filterValue) || country.value?.toLowerCase().includes(filterValue), - ); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css deleted file mode 100644 index 57610ddd6..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.css +++ /dev/null @@ -1,27 +0,0 @@ -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} - -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} - -.value-field { - flex: 1; -} - -.field-couple { - display: grid; - grid-template-columns: 1fr 1fr; - grid-column-gap: 16px; -} - -.between-fields { - display: flex; - flex-direction: column; - gap: 8px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html deleted file mode 100644 index 4e999a8b3..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.html +++ /dev/null @@ -1,82 +0,0 @@ -
- - - last hour - last day - last week - last month - last year - on - after - before - on or after - on or before - between - in - - - - @if (filterMode === 'between') { -
-
- - {{normalizedLabel()}} (from date) - - - - - (from time) - - -
- -
- - {{normalizedLabel()}} (to date) - - - - - (to time) - - -
-
- } @else if (filterMode === 'in') { - - {{normalizedLabel()}} (comma-separated ISO datetimes) - - - } @else if (!isPresetMode()) { -
- - {{normalizedLabel()}} (date) - - - - - {{normalizedLabel()}} (time) - - -
- } -
diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts deleted file mode 100644 index e4fc411e1..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { DateTimeFilterComponent } from './date-time.component'; - -describe('DateTimeFilterComponent', () => { - let component: DateTimeFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateTimeFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateTimeFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should default to last_day filter mode', () => { - expect(component.filterMode).toEqual('last_day'); - }); - - it('should prepare date and time for date and time inputs when value is provided', () => { - component.value = '2021-06-26T07:22:00.603'; - component.ngOnInit(); - - expect(component.date).toEqual('2021-06-26'); - expect(component.time).toEqual('07:22:00'); - expect(component.filterMode).toEqual('gte'); - }); - - it('should send onChange event with new date value', () => { - component.filterMode = 'gte'; - component.date = '2021-08-26'; - component.time = '07:22:00'; - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - component.onDateChange(); - - expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T07:22:00Z'); - expect(comparatorEvent).toHaveBeenCalledWith('gte'); - }); - - it('should send onChange event with new time value', () => { - component.filterMode = 'lt'; - component.date = '2021-07-26'; - component.time = '07:20:00'; - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - component.onTimeChange(); - - expect(fieldEvent).toHaveBeenCalledWith('2021-07-26T07:20:00Z'); - expect(comparatorEvent).toHaveBeenCalledWith('lt'); - }); - - it('should identify preset modes correctly', () => { - component.filterMode = 'last_hour'; - expect(component.isPresetMode()).toBe(true); - - component.filterMode = 'last_day'; - expect(component.isPresetMode()).toBe(true); - - component.filterMode = 'last_week'; - expect(component.isPresetMode()).toBe(true); - - component.filterMode = 'last_month'; - expect(component.isPresetMode()).toBe(true); - - component.filterMode = 'last_year'; - expect(component.isPresetMode()).toBe(true); - - component.filterMode = 'eq'; - expect(component.isPresetMode()).toBe(false); - - component.filterMode = 'gt'; - expect(component.isPresetMode()).toBe(false); - }); - - it('should emit gte comparator and computed value for preset modes', () => { - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - - component.onFilterModeChange('last_hour'); - - expect(comparatorEvent).toHaveBeenCalledWith('gte'); - expect(fieldEvent).toHaveBeenCalled(); - const emittedValue = fieldEvent.mock.calls[0][0] as string; - expect(emittedValue).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); - }); - - it('should emit correct comparator for custom modes', () => { - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - - component.onFilterModeChange('gt'); - expect(comparatorEvent).toHaveBeenCalledWith('gt'); - - component.onFilterModeChange('lt'); - expect(comparatorEvent).toHaveBeenCalledWith('lt'); - - component.onFilterModeChange('eq'); - expect(comparatorEvent).toHaveBeenCalledWith('eq'); - }); - - it('should emit datetime value when switching to custom mode with existing date', () => { - component.date = '2021-08-26'; - component.time = '10:00:00'; - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - - component.onFilterModeChange('gt'); - - expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T10:00:00Z'); - }); - - it('should default time to 00:00 on date change if time is not set', () => { - component.filterMode = 'eq'; - component.date = '2021-08-26'; - component.time = undefined; - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - component.onDateChange(); - - expect(component.time).toEqual('00:00:00'); - expect(fieldEvent).toHaveBeenCalledWith('2021-08-26T00:00:00Z'); - }); - - it('should emit between comparator with [lower, upper] ISO strings when BETWEEN bounds change', () => { - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - component.onFilterModeChange('between'); - - component.lowerDate = '2024-01-01'; - component.lowerTime = '00:00:00'; - component.onBetweenLowerChange(); - - expect(comparatorEvent).toHaveBeenCalledWith('between'); - expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', null]); - - component.upperDate = '2024-01-31'; - component.upperTime = '23:59:59'; - component.onBetweenUpperChange(); - - expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', '2024-01-31T23:59:59Z']); - }); - - it('should restore BETWEEN bounds from a two-element array on init', () => { - component.value = ['2024-01-01T00:00:00Z', '2024-01-31T23:59:59Z']; - component.filterMode = 'between'; - component.ngOnInit(); - - expect(component.lowerDate).toEqual('2024-01-01'); - expect(component.lowerTime).toEqual('00:00:00'); - expect(component.upperDate).toEqual('2024-01-31'); - expect(component.upperTime).toEqual('23:59:59'); - }); - - it('should parse comma-separated text into array on IN text change', () => { - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - const comparatorEvent = vi.spyOn(component.onComparatorChange, 'emit'); - component.onFilterModeChange('in'); - - component.onInTextChange('2024-01-01T00:00:00Z, 2024-02-01T00:00:00Z'); - - expect(comparatorEvent).toHaveBeenCalledWith('in'); - expect(fieldEvent).toHaveBeenLastCalledWith(['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']); - }); - - it('should emit undefined when IN text is empty', () => { - const fieldEvent = vi.spyOn(component.onFieldChange, 'emit'); - component.onFilterModeChange('in'); - - component.onInTextChange(' '); - - expect(fieldEvent).toHaveBeenLastCalledWith(undefined); - }); - - it('should restore IN text from array value on init', () => { - component.value = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']; - component.filterMode = 'in'; - component.ngOnInit(); - - expect(component.inValueText).toBe('2024-01-01T00:00:00Z, 2024-02-01T00:00:00Z'); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.stories.ts deleted file mode 100644 index cb843179e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateTimeFilterComponent } from './date-time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'datetime', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/DateTime', - component: DateTimeFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '2024-01-15T10:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts deleted file mode 100644 index e632ba38a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date-time/date-time.component.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { format, subDays, subHours, subMonths, subYears } from 'date-fns'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-date-time', - templateUrl: './date-time.component.html', - styleUrls: ['./date-time.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], -}) -export class DateTimeFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: string | string[]; - @ViewChild('inputElement') inputElement: ElementRef; - - public filterMode: string = 'last_day'; - public date: string; - public time: string; - - public lowerDate: string; - public lowerTime: string; - public upperDate: string; - public upperTime: string; - - public inValueText: string = ''; - - private _presetModes = ['last_hour', 'last_day', 'last_week', 'last_month', 'last_year']; - - ngOnInit(): void { - if (this.filterMode === 'between' && Array.isArray(this.value)) { - this._restoreBetween(this.value); - return; - } - - if (this.filterMode === 'in' && Array.isArray(this.value)) { - this.inValueText = this.value.join(', '); - return; - } - - if (typeof this.value === 'string' && this.value) { - const datetime = new Date(this.value); - this.date = format(datetime, 'yyyy-MM-dd'); - this.time = format(datetime, 'HH:mm:ss'); - this.filterMode = 'gte'; - } - } - - ngAfterViewInit(): void { - if (this.filterMode === 'between' || this.filterMode === 'in') { - this.onComparatorChange.emit(this.filterMode); - } else if (this.value) { - this.onComparatorChange.emit(this.filterMode); - } else { - const value = this._computePresetValue(this.filterMode); - this.onFieldChange.emit(value); - this.onComparatorChange.emit('gte'); - } - - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } - - onFilterModeChange(mode: string): void { - const previous = this.filterMode; - this.filterMode = mode; - - if (this._presetModes.includes(mode)) { - const value = this._computePresetValue(mode); - this.onFieldChange.emit(value); - this.onComparatorChange.emit('gte'); - return; - } - - if (mode === 'in' || mode === 'between') { - if (previous !== mode) { - this.inValueText = ''; - this.lowerDate = undefined; - this.lowerTime = undefined; - this.upperDate = undefined; - this.upperTime = undefined; - this.onFieldChange.emit(undefined); - } - this.onComparatorChange.emit(mode); - return; - } - - this.onComparatorChange.emit(mode); - if (this.date) { - const time = this.time || '00:00'; - const datetime = `${this.date}T${time}Z`; - this.onFieldChange.emit(datetime); - } - } - - onDateChange(): void { - if (!this.time) this.time = '00:00:00'; - const datetime = `${this.date}T${this.time}Z`; - this.onFieldChange.emit(datetime); - this.onComparatorChange.emit(this.filterMode); - } - - onTimeChange(): void { - const datetime = `${this.date}T${this.time}Z`; - this.onFieldChange.emit(datetime); - this.onComparatorChange.emit(this.filterMode); - } - - onBetweenLowerChange(): void { - const lower = this._composeDateTime(this.lowerDate, this.lowerTime); - const upper = this._composeDateTime(this.upperDate, this.upperTime); - this.onFieldChange.emit([lower, upper]); - this.onComparatorChange.emit('between'); - } - - onBetweenUpperChange(): void { - const lower = this._composeDateTime(this.lowerDate, this.lowerTime); - const upper = this._composeDateTime(this.upperDate, this.upperTime); - this.onFieldChange.emit([lower, upper]); - this.onComparatorChange.emit('between'); - } - - onInTextChange(text: string): void { - this.inValueText = text; - const parts = text - .split(',') - .map((v) => v.trim()) - .filter((v) => v.length > 0); - - if (parts.length === 0) { - this.onFieldChange.emit(undefined); - return; - } - - this.onFieldChange.emit(parts); - this.onComparatorChange.emit('in'); - } - - isPresetMode(): boolean { - return this._presetModes.includes(this.filterMode); - } - - private _restoreBetween(value: string[]): void { - if (value[0]) { - const iso = new Date(value[0]).toISOString(); - this.lowerDate = iso.slice(0, 10); - this.lowerTime = iso.slice(11, 19); - } - if (value[1]) { - const iso = new Date(value[1]).toISOString(); - this.upperDate = iso.slice(0, 10); - this.upperTime = iso.slice(11, 19); - } - } - - private _composeDateTime(date: string, time: string): string | null { - if (!date) return null; - const t = time || '00:00:00'; - return `${date}T${t}Z`; - } - - private _computePresetValue(mode: string): string { - const now = new Date(); - let targetDate: Date; - - switch (mode) { - case 'last_hour': - targetDate = subHours(now, 1); - break; - case 'last_day': - targetDate = subDays(now, 1); - break; - case 'last_week': - targetDate = subDays(now, 7); - break; - case 'last_month': - targetDate = subMonths(now, 1); - break; - case 'last_year': - targetDate = subYears(now, 1); - break; - default: - targetDate = now; - } - - return targetDate.toISOString().replace(/\.\d{3}Z$/, 'Z'); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.css b/frontend/src/app/components/ui-components/filter-fields/date/date.component.css deleted file mode 100644 index 03fe2b8ac..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.date-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.html b/frontend/src/app/components/ui-components/filter-fields/date/date.component.html deleted file mode 100644 index 376e16138..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/date/date.component.spec.ts deleted file mode 100644 index 865159f87..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { DateFilterComponent } from './date.component'; - -describe('DateFilterComponent', () => { - let component: DateFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should prepare date for date input', () => { - component.value = '2021-06-26T07:22:00.603Z'; - component.ngOnInit(); - - expect(component.date).toEqual('2021-06-26'); - }); - - it('should remain date undefined if there is no value', () => { - component.value = null; - component.ngOnInit(); - - expect(component.date).not.toBeDefined(); - }); - - it('should send onChange event with new date value', () => { - component.date = '2021-07-26'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onDateChange(); - expect(event).toHaveBeenCalledWith('2021-07-26'); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/date/date.component.stories.ts deleted file mode 100644 index 91e8266bd..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateFilterComponent } from './date.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'date', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Date', - component: DateFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '2024-01-15', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts b/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts deleted file mode 100644 index 17b430f11..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/date/date.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { format } from 'date-fns'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-date', - templateUrl: './date.component.html', - styleUrls: ['./date.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class DateFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - static type = 'datetime'; - public date: string; - - ngOnInit(): void { - if (this.value) { - const datetime = new Date(this.value); - this.date = format(datetime, 'yyyy-MM-dd'); - } - } - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } - - onDateChange() { - this.onFieldChange.emit(this.date); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css deleted file mode 100644 index 84f358170..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.css +++ /dev/null @@ -1,31 +0,0 @@ -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} - -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} - -.value-field { - flex: 1; - min-width: 0; -} - -.between-fields { - display: flex; - gap: 8px; - align-items: flex-start; -} - -.between-fields ::ng-deep > ndc-dynamic { - flex: 1; - min-width: 0; -} - -.between-fields ::ng-deep mat-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html deleted file mode 100644 index 73509c882..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.html +++ /dev/null @@ -1,49 +0,0 @@ -
- - - @for (option of comparatorOptions; track option.value) { - {{ option.label }} - } - - - - @if (comparator === 'in') { - - {{ normalizedLabel() }} (comma-separated) - - - } @else if (comparator === 'between') { -
- - -
- } @else { -
- -
- } -
diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts deleted file mode 100644 index 191f97d35..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { vi } from 'vitest'; -import { DateFilterComponent } from '../date/date.component'; -import { NumberFilterComponent } from '../number/number.component'; -import { TextFilterComponent } from '../text/text.component'; -import { DefaultFilterComponent } from './default-filter.component'; - -describe('DefaultFilterComponent', () => { - let component: DefaultFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DefaultFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DefaultFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should expose text comparator options when wrapping a text-type filter', () => { - component.valueComponent = TextFilterComponent; - expect(component.comparatorOptions.map((o) => o.value)).toEqual([ - 'startswith', - 'endswith', - 'eq', - 'contains', - 'icontains', - 'empty', - ]); - }); - - it('should expose number comparator options when wrapping a number-type filter', () => { - component.valueComponent = NumberFilterComponent; - expect(component.comparatorOptions.map((o) => o.value)).toEqual(['eq', 'gt', 'lt', 'gte', 'lte', 'in', 'between']); - }); - - it('should expose in/between for datetime inner components', () => { - component.valueComponent = DateFilterComponent; - const values = component.comparatorOptions.map((o) => o.value); - expect(values).toContain('in'); - expect(values).toContain('between'); - }); - - it('should emit initial comparator on view init', () => { - const spy = vi.spyOn(component.onComparatorChange, 'emit'); - component.comparator = 'contains'; - component.ngAfterViewInit(); - expect(spy).toHaveBeenCalledWith('contains'); - }); - - it('should emit value and reset it when "empty" comparator is selected', () => { - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - const cmpSpy = vi.spyOn(component.onComparatorChange, 'emit'); - component.value = 'foo'; - component.onComparatorSelect('empty'); - expect(component.value).toBe(''); - expect(valueSpy).toHaveBeenCalledWith(''); - expect(cmpSpy).toHaveBeenCalledWith('empty'); - }); - - it('should emit comparator without resetting value for non-empty selections', () => { - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - const cmpSpy = vi.spyOn(component.onComparatorChange, 'emit'); - component.value = 'foo'; - component.onComparatorSelect('contains'); - expect(component.value).toBe('foo'); - expect(valueSpy).not.toHaveBeenCalled(); - expect(cmpSpy).toHaveBeenCalledWith('contains'); - }); - - it('should re-emit value changes from inner widget via onValueChange', () => { - const spy = vi.spyOn(component.onFieldChange, 'emit'); - component.onValueChange('bar'); - expect(component.value).toBe('bar'); - expect(spy).toHaveBeenCalledWith('bar'); - }); - - it('should mark inner inputs as readonly when comparator is "empty"', () => { - component.comparator = 'empty'; - expect(component.innerInputs.readonly).toBe(true); - }); - - it('should parse comma-separated text into an array on IN text change', () => { - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - component.valueComponent = NumberFilterComponent; - component.comparator = 'in'; - component.onInTextChange('1, 2,3 , 4'); - - expect(component.inValueText).toBe('1, 2,3 , 4'); - expect(valueSpy).toHaveBeenCalledWith(['1', '2', '3', '4']); - }); - - it('should emit undefined when IN text is empty', () => { - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - component.valueComponent = NumberFilterComponent; - component.comparator = 'in'; - component.onInTextChange(' '); - - expect(valueSpy).toHaveBeenCalledWith(undefined); - }); - - it('should restore inValueText from an array value on init when comparator is in', () => { - component.valueComponent = NumberFilterComponent; - component.comparator = 'in'; - component.value = ['1', '2', '3']; - component.ngOnInit(); - - expect(component.inValueText).toBe('1, 2, 3'); - }); - - it('should emit two-element array when BETWEEN bounds change', () => { - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - component.valueComponent = NumberFilterComponent; - component.comparator = 'between'; - - component.onBetweenLowerChange('10'); - expect(valueSpy).toHaveBeenLastCalledWith(['10', undefined]); - - component.onBetweenUpperChange('20'); - expect(valueSpy).toHaveBeenLastCalledWith(['10', '20']); - }); - - it('should restore BETWEEN bounds from a two-element array on init', () => { - component.valueComponent = NumberFilterComponent; - component.comparator = 'between'; - component.value = ['5', '15']; - component.ngOnInit(); - - expect(component.betweenLower).toBe('5'); - expect(component.betweenUpper).toBe('15'); - }); - - it('should clear single-value state when switching to IN', () => { - component.valueComponent = NumberFilterComponent; - component.value = '42'; - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - - component.onComparatorSelect('in'); - - expect(component.comparator).toBe('in'); - expect(valueSpy).toHaveBeenCalledWith(undefined); - }); - - it('should clear single-value state when switching to BETWEEN', () => { - component.valueComponent = NumberFilterComponent; - component.value = '42'; - const valueSpy = vi.spyOn(component.onFieldChange, 'emit'); - - component.onComparatorSelect('between'); - - expect(component.comparator).toBe('between'); - expect(valueSpy).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts b/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts deleted file mode 100644 index f09d05461..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/default-filter/default-filter.component.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, Input, OnInit, Type } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -const TEXT_COMPARATORS = [ - { value: 'startswith', label: 'starts with' }, - { value: 'endswith', label: 'ends with' }, - { value: 'eq', label: 'equal' }, - { value: 'contains', label: 'contains' }, - { value: 'icontains', label: 'not contains' }, - { value: 'empty', label: 'is empty' }, -]; - -const NUMBER_COMPARATORS = [ - { value: 'eq', label: 'equal' }, - { value: 'gt', label: 'greater than' }, - { value: 'lt', label: 'less than' }, - { value: 'gte', label: 'greater than or equal' }, - { value: 'lte', label: 'less than or equal' }, - { value: 'in', label: 'in' }, - { value: 'between', label: 'between' }, -]; - -@Component({ - selector: 'app-filter-default', - templateUrl: './default-filter.component.html', - styleUrls: ['./default-filter.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - DynamicModule, - SignalComponentIoModule, - ], -}) -export class DefaultFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: any; - @Input() comparator: string = 'eq'; - @Input() valueComponent: Type; - - public inValueText: string = ''; - public betweenLower: any; - public betweenUpper: any; - - ngOnInit(): void { - if (this.comparator === 'in' && Array.isArray(this.value)) { - this.inValueText = this.value.join(', '); - } else if (this.comparator === 'between' && Array.isArray(this.value)) { - this.betweenLower = this.value[0]; - this.betweenUpper = this.value[1]; - } - } - - ngAfterViewInit(): void { - this.onComparatorChange.emit(this.comparator); - } - - get comparatorOptions() { - const innerType = (this.valueComponent as { type?: string })?.type; - return innerType === 'text' ? TEXT_COMPARATORS : NUMBER_COMPARATORS; - } - - get innerInputs(): Record { - return { - key: this.key(), - label: this.label(), - value: this.value, - readonly: this.comparator === 'empty', - structure: this.structure(), - relations: this.relations(), - autofocus: this.autofocus(), - }; - } - - get lowerInputs(): Record { - return { - key: `${this.key()}-lower`, - label: `${this.label()} (from)`, - value: this.betweenLower, - structure: this.structure(), - relations: this.relations(), - autofocus: this.autofocus(), - }; - } - - get upperInputs(): Record { - return { - key: `${this.key()}-upper`, - label: `${this.label()} (to)`, - value: this.betweenUpper, - structure: this.structure(), - relations: this.relations(), - }; - } - - onComparatorSelect(comparator: string): void { - const previous = this.comparator; - this.comparator = comparator; - - if (comparator === 'empty') { - this.value = ''; - this.onFieldChange.emit(''); - } else if (comparator === 'in' || comparator === 'between') { - if (previous !== comparator) { - this.value = undefined; - this.inValueText = ''; - this.betweenLower = undefined; - this.betweenUpper = undefined; - this.onFieldChange.emit(undefined); - } - } - - this.onComparatorChange.emit(comparator); - } - - onValueChange = (val: any): void => { - this.value = val; - this.onFieldChange.emit(val); - }; - - onInTextChange(text: string): void { - this.inValueText = text; - const parts = text - .split(',') - .map((v) => v.trim()) - .filter((v) => v.length > 0); - - if (parts.length === 0) { - this.value = undefined; - this.onFieldChange.emit(undefined); - return; - } - - this.value = parts; - this.onFieldChange.emit(parts); - } - - onBetweenLowerChange = (val: any): void => { - this.betweenLower = val; - this.value = [this.betweenLower, this.betweenUpper]; - this.onFieldChange.emit(this.value); - }; - - onBetweenUpperChange = (val: any): void => { - this.betweenUpper = val; - this.value = [this.betweenLower, this.betweenUpper]; - this.onFieldChange.emit(this.value); - }; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.css b/frontend/src/app/components/ui-components/filter-fields/email/email.component.css deleted file mode 100644 index 3b1f34518..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/email/email.component.css +++ /dev/null @@ -1,15 +0,0 @@ -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} - -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} - -.value-field { - flex: 1; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.html b/frontend/src/app/components/ui-components/filter-fields/email/email.component.html deleted file mode 100644 index 24f5f9351..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/email/email.component.html +++ /dev/null @@ -1,32 +0,0 @@ -
- - - equal - domain equal - contains - starts with - ends with - is empty - - - - @if (filterMode === 'domain') { - - {{normalizedLabel()}} (domain) - @  - - - } @else if (filterMode !== 'empty') { - - {{normalizedLabel()}} - - - } -
diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts deleted file mode 100644 index 0e171dc2a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/email/email.component.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { EmailFilterComponent } from './email.component'; - -describe('EmailFilterComponent', () => { - let component: EmailFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [EmailFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(EmailFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should default to eq filter mode', () => { - fixture.detectChanges(); - expect(component.filterMode).toBe('eq'); - }); - - it('should not emit comparator in default eq mode', () => { - const emitted: string[] = []; - component.onComparatorChange.subscribe((v: string) => emitted.push(v)); - - component.ngOnInit(); - component.ngAfterViewInit(); - - expect(emitted).toEqual([]); - }); - - it('should emit endswith comparator when initialized with @domain value', () => { - component.value = '@example.com'; - const emitted: string[] = []; - component.onComparatorChange.subscribe((v: string) => emitted.push(v)); - - component.ngOnInit(); - component.ngAfterViewInit(); - - expect(emitted).toEqual(['endswith']); - }); - - it('should prefix domain value with @ on domain change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.onDomainChange('gmail.com'); - - expect(component.value).toBe('@gmail.com'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('@gmail.com'); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('endswith'); - }); - - it('should switch to eq mode and emit plain text', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.value = 'user@test.com'; - component.onFilterModeChange('eq'); - - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('user@test.com'); - }); - - it('should switch to empty mode', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.detectChanges(); - - component.onFilterModeChange('empty'); - - expect(component.value).toBe(''); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('empty'); - }); - - it('should parse existing @domain value on init', () => { - component.value = '@example.com'; - fixture.detectChanges(); - - expect(component.filterMode).toBe('domain'); - expect(component.domainValue).toBe('example.com'); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts b/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts deleted file mode 100644 index 383eff263..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/email/email.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-email', - templateUrl: './email.component.html', - styleUrls: ['./email.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule], -}) -export class EmailFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - public filterMode: string = 'eq'; - public domainValue: string = ''; - - ngOnInit(): void { - if (this.value?.startsWith('@')) { - this.filterMode = 'domain'; - this.domainValue = this.value.substring(1); - } - } - - ngAfterViewInit(): void { - if (this.filterMode !== 'eq') { - this.onComparatorChange.emit(this.filterMode === 'domain' ? 'endswith' : this.filterMode); - } - - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } - - onFilterModeChange(mode: string): void { - this.filterMode = mode; - - if (mode === 'domain') { - this.value = this.domainValue ? `@${this.domainValue}` : ''; - this.onComparatorChange.emit('endswith'); - } else if (mode === 'empty') { - this.value = ''; - this.onComparatorChange.emit('empty'); - } else { - this.onComparatorChange.emit(mode); - } - - this.onFieldChange.emit(this.value); - } - - onDomainChange(domain: string): void { - this.domainValue = domain; - this.value = domain ? `@${domain}` : ''; - this.onFieldChange.emit(this.value); - this.onComparatorChange.emit('endswith'); - } - - onValueChange(text: string): void { - this.value = text; - this.onFieldChange.emit(this.value); - this.onComparatorChange.emit(this.filterMode); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/file/file.component.css b/frontend/src/app/components/ui-components/filter-fields/file/file.component.css deleted file mode 100644 index d3e76481b..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/file/file.component.css +++ /dev/null @@ -1,39 +0,0 @@ -.file { - margin-bottom: 24px; -} - -.header { - display: flex; - align-items: center; - margin-bottom: 20px; -} - -.file-type { - margin-right: 20px; -} - -.file-raw-data { - width: 100%; -} - -.file-input { - display: none; -} - -.file-uploader-appearance { - margin-bottom: 20px; -} - -.file-label { - margin-right: 20px; -} - -.file-name { - color: rgba(0, 0, 0, 0.5); - font-style: italic; - margin-right: 20px; -} - -.upload-btn { - margin-right: 12px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/file/file.component.html b/frontend/src/app/components/ui-components/filter-fields/file/file.component.html deleted file mode 100644 index 9c9f09849..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/file/file.component.html +++ /dev/null @@ -1,4 +0,0 @@ -
- Not available for filtering -
- diff --git a/frontend/src/app/components/ui-components/filter-fields/file/file.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/file/file.component.spec.ts deleted file mode 100644 index e6d56f3e2..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/file/file.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatRadioModule } from '@angular/material/radio'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { FileFilterComponent } from './file.component'; - -describe('FileFilterComponent', () => { - let component: FileFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, MatRadioModule, FileFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FileFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/file/file.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/file/file.component.stories.ts deleted file mode 100644 index 4a3241f70..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/file/file.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { FileFilterComponent } from './file.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'blob', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/File', - component: FileFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/file/file.component.ts b/frontend/src/app/components/ui-components/filter-fields/file/file.component.ts deleted file mode 100644 index ae16ebc74..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/file/file.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-file', - templateUrl: './file.component.html', - styleUrls: ['./file.component.css'], - imports: [CommonModule], -}) -export class FileFilterComponent extends BaseFilterFieldComponent { - static type = 'file'; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.css b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.css deleted file mode 100644 index 246754ac9..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.css +++ /dev/null @@ -1,41 +0,0 @@ -.full-width { - position: relative; - width: 100%; -} - -.loader { - position: absolute; - top: 12px; - right: 12px; -} - -.disabled { - pointer-events: none; -} - -.foreign-key { - display: grid; - grid-template-columns: 1fr 36px; -} - -.foreign-key ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; -} - -@media (width <= 600px) { - .foreign-key__link-icon { - margin-top: 6px; - } -} - -.foreign-key__link { - display: flex; - /* align-items: center; */ - justify-content: flex-end; - color: inherit; -} - -.foreign-key__link-icon { - margin-left: auto; - margin-top: 12px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html deleted file mode 100644 index 22c5cfba7..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.html +++ /dev/null @@ -1,42 +0,0 @@ - - - -
- - {{normalizedLabel()}} - @if (fetching()) { - - } - - - @for (suggestion of suggestions(); track suggestion.fieldValue) { - - {{suggestion.displayString}} - - } - - - - - open_in_new - - -
- - - - - - diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts deleted file mode 100644 index 27b5990f1..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.spec.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { ForeignKeyFilterComponent } from './foreign-key.component'; - -describe('ForeignKeyFilterComponent', () => { - let component: ForeignKeyFilterComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let connectionsService: ConnectionsService; - - const structureNetwork = [ - { - column_name: 'id', - column_default: "nextval('customers_id_seq'::regclass)", - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }, - { - column_name: 'firstname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'lastname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'email', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }, - { - column_name: 'age', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: null, - }, - ]; - - const usersTableNetwork = { - rows: [ - { - id: 33, - firstname: 'Alex', - lastname: 'Taylor', - email: 'new-user-5@email.com', - age: 24, - }, - { - id: 34, - firstname: 'Alex', - lastname: 'Johnson', - email: 'new-user-4@email.com', - age: 24, - }, - { - id: 35, - firstname: 'Alex', - lastname: 'Smith', - email: 'some-new@email.com', - age: 24, - }, - ], - primaryColumns: [ - { - column_name: 'id', - data_type: 'integer', - }, - ], - pagination: { - total: 30, - lastPage: 1, - perPage: 30, - currentPage: 1, - }, - sortable_by: [], - ordering: 'ASC', - structure: structureNetwork, - foreignKeys: [], - }; - - const fakeRelations = { - autocomplete_columns: ['firstname', 'lastname', 'email'], - column_name: 'userId', - constraint_name: '', - referenced_column_name: 'id', - referenced_table_name: 'users', - column_default: '', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatAutocompleteModule, - MatDialogModule, - ForeignKeyFilterComponent, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - ], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ForeignKeyFilterComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('relations', fakeRelations); - tablesService = TestBed.inject(TablesService); - connectionsService = TestBed.inject(ConnectionsService); - // Mock the connectionID getter before ngOnInit runs - vi.spyOn(connectionsService, 'currentConnectionID', 'get').mockReturnValue('12345678'); - // Don't call fixture.detectChanges() here - let tests set up mocks first - }); - - it('should create', () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should fill initial dropdown values when identity_column is set', async () => { - const usersTableNetworkWithIdentityColumn = { ...usersTableNetwork, identity_column: 'lastname' }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetworkWithIdentityColumn)); - - component.connectionID = '12345678'; - component.value = '33'; // Must be truthy to trigger currentDisplayedString setting - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toEqual('lastname'); - expect(component.currentDisplayedString).toEqual('Taylor (Alex | new-user-5@email.com)'); - expect(component.currentFieldValue).toEqual(33); - - expect(component.suggestions()).toEqual([ - { - displayString: 'Taylor (Alex | new-user-5@email.com)', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Johnson (Alex | new-user-4@email.com)', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Smith (Alex | some-new@email.com)', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should fill initial dropdown values when identity_column is not set', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - - component.value = '33'; // Must be truthy to trigger currentDisplayedString setting - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toBeUndefined; - expect(component.currentDisplayedString).toEqual('Alex | Taylor | new-user-5@email.com'); - expect(component.currentFieldValue).toEqual(33); - - expect(component.suggestions()).toEqual([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should fill initial dropdown values when autocomplete_columns is not set', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('relations', { - autocomplete_columns: [], - column_name: 'userId', - constraint_name: '', - referenced_column_name: 'id', - referenced_table_name: 'users', - column_default: '', - }); - component.value = '33'; // Must be truthy to trigger currentDisplayedString setting - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toBeUndefined; - expect(component.currentDisplayedString).toEqual('33 | Alex | Taylor | new-user-5@email.com | 24'); - expect(component.currentFieldValue).toEqual(33); - - expect(component.suggestions()).toEqual([ - { - displayString: '33 | Alex | Taylor | new-user-5@email.com | 24', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: '34 | Alex | Johnson | new-user-4@email.com | 24', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: '35 | Alex | Smith | some-new@email.com | 24', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should set current value if necessary row is in suggestions list', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - fixture.detectChanges(); - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - component.currentDisplayedString = 'Alex | Johnson | new-user-4@email.com'; - - await component.fetchSuggestions(); - - expect(component.currentFieldValue).toEqual(34); - }); - - it('should fetch suggestions list if user types search query and identity column is set', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - fixture.detectChanges(); - - const searchSuggestionsNetwork = { - rows: [ - { - id: 23, - firstname: 'John', - lastname: 'Taylor', - email: 'new-user-0@email.com', - age: 24, - }, - { - id: 24, - firstname: 'John', - lastname: 'Johnson', - email: 'new-user-1@email.com', - age: 24, - }, - ], - primaryColumns: [{ column_name: 'id', data_type: 'integer' }], - identity_column: 'lastname', - }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - - fixture.componentRef.setInput('relations', fakeRelations); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'John'; - await component.fetchSuggestions(); - - expect(component.suggestions()).toEqual([ - { - displayString: 'Taylor (John | new-user-0@email.com)', - primaryKeys: { id: 23 }, - fieldValue: 23, - }, - { - displayString: 'Johnson (John | new-user-1@email.com)', - primaryKeys: { id: 24 }, - fieldValue: 24, - }, - ]); - }); - - it('should fetch suggestions list if user types search query and show No matches message if the list is empty', async () => { - const searchSuggestionsNetwork = { - rows: [], - }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'skjfhskjdf'; - await component.fetchSuggestions(); - - expect(component.suggestions()).toEqual([ - { - displayString: 'No field starts with "skjfhskjdf" in foreign entity.', - }, - ]); - }); - - it('should fetch suggestions list if user types search query and identity column is not set', async () => { - const searchSuggestionsNetwork = { - rows: [ - { - id: 23, - firstname: 'John', - lastname: 'Taylor', - email: 'new-user-0@email.com', - age: 24, - }, - { - id: 24, - firstname: 'John', - lastname: 'Johnson', - email: 'new-user-1@email.com', - age: 24, - }, - ], - primaryColumns: [{ column_name: 'id', data_type: 'integer' }], - }; - - const fakeFetchTable = vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - component.connectionID = '12345678'; - fixture.componentRef.setInput('relations', fakeRelations); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'Alex'; - await component.fetchSuggestions(); - - fixture.detectChanges(); - - expect(fakeFetchTable).toHaveBeenCalledWith({ - connectionID: '12345678', - tableName: component.relations().referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: component.currentDisplayedString, - referencedColumn: component.relations().referenced_column_name, - }); - - expect(component.suggestions()).toEqual([ - { - displayString: 'John | Taylor | new-user-0@email.com', - primaryKeys: { id: 23 }, - fieldValue: 23, - }, - { - displayString: 'John | Johnson | new-user-1@email.com', - primaryKeys: { id: 24 }, - fieldValue: 24, - }, - ]); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.stories.ts deleted file mode 100644 index ea95f0e2a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ForeignKeyFilterComponent } from './foreign-key.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/ForeignKey', - component: ForeignKeyFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '1', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts deleted file mode 100644 index 3aa51ff68..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/foreign-key/foreign-key.component.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSpinner } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -interface FetchTableResponse { - rows: Record[]; - primaryColumns: { column_name: string; data_type: string }[]; - identity_column?: string; -} - -interface Suggestion { - displayString: string; - primaryKeys?: Record; - fieldValue?: unknown; -} - -@Component({ - selector: 'app-filter-foreign-key', - templateUrl: './foreign-key.component.html', - styleUrls: ['./foreign-key.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatAutocompleteModule, - MatIconModule, - MatTooltipModule, - MatSpinner, - RouterModule, - ], -}) -export class ForeignKeyFilterComponent extends BaseFilterFieldComponent { - @Input() value; - - public connectionID: string; - public currentDisplayedString: string; - public currentFieldValue: unknown; - public currentFieldQueryParams: Record; - public suggestions = signal([]); - public fetching = signal(true); - public identityColumn: string; - public primaeyKeys: { data_type: string; column_name: string }[]; - - private _debounceTimer: ReturnType; - - constructor( - private _tables: TablesService, - private _connections: ConnectionsService, - ) { - super(); - } - - async ngOnInit(): Promise { - this.connectionID = this._connections.currentConnectionID; - const rels = this.relations(); - - if (rels) { - try { - const res = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: rels.referenced_table_name, - requstedPage: 1, - chunkSize: 10, - foreignKeyRowName: rels.referenced_column_name, - foreignKeyRowValue: this.value, - }), - )) as FetchTableResponse; - - if (res.rows.length) { - this.identityColumn = res.identity_column; - const modifiedRow = this.getModifiedRow(res.rows[0]); - if (this.value) { - this.currentDisplayedString = this.identityColumn - ? `${res.rows[0][this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '); - this.currentFieldValue = res.rows[0][rels.referenced_column_name]; - this.currentFieldQueryParams = Object.assign( - {}, - ...res.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: res.rows[0][primaeyKey.column_name], - })), - ); - this.onFieldChange.emit(this.currentFieldValue); - } - } - - const suggestionsRes = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: rels.referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: '', - referencedColumn: rels.referenced_column_name, - }), - )) as FetchTableResponse; - - this.identityColumn = suggestionsRes.identity_column; - this.suggestions.set( - suggestionsRes.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...suggestionsRes.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[rels.referenced_column_name], - }; - }), - ); - this.fetching.set(false); - } catch (error) { - console.error('Error loading foreign key data:', error); - this.fetching.set(false); - } - } - } - - onSearchInput(): void { - clearTimeout(this._debounceTimer); - this._debounceTimer = setTimeout(() => { - if (this.currentDisplayedString === '') this.onFieldChange.emit(null); - this.fetchSuggestions(); - }, 500); - } - - async fetchSuggestions(): Promise { - const currentRow = this.suggestions()?.find( - (suggestion) => suggestion.displayString === this.currentDisplayedString, - ); - const rels = this.relations(); - if (currentRow !== undefined) { - this.currentFieldValue = currentRow.fieldValue; - this.onFieldChange.emit(this.currentFieldValue); - } else { - this.fetching.set(true); - try { - const res = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: rels.referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: this.currentDisplayedString, - referencedColumn: rels.referenced_column_name, - }), - )) as FetchTableResponse; - - this.identityColumn = res.identity_column; - if (res.rows.length === 0) { - this.suggestions.set([ - { - displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`, - }, - ]); - } else { - this.suggestions.set( - res.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...res.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[rels.referenced_column_name], - }; - }), - ); - } - this.fetching.set(false); - } catch (error) { - console.error('Error fetching suggestions:', error); - this.fetching.set(false); - } - } - } - - getModifiedRow(row: Record): Record { - const rels = this.relations(); - let modifiedRow: Record; - if (rels.autocomplete_columns && rels.autocomplete_columns.length > 0) { - let autocompleteColumns = [...rels.autocomplete_columns]; - if (this.identityColumn) autocompleteColumns.splice(rels.autocomplete_columns.indexOf(this.identityColumn), 1); - modifiedRow = autocompleteColumns.reduce>( - (rowObject, columnName) => ((rowObject[columnName] = row[columnName]), rowObject), - {}, - ); - } else { - modifiedRow = Object.entries(row).reduce>((rowObject, [columnName, value]) => { - if (value) { - rowObject[columnName] = value; - } - return rowObject; - }, {}); - } - return modifiedRow; - } - - updateRelatedLink(e: MatAutocompleteSelectedEvent) { - this.currentFieldQueryParams = this.suggestions().find( - (suggestion) => suggestion.displayString === e.option.value, - ).primaryKeys; - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.css b/frontend/src/app/components/ui-components/filter-fields/id/id.component.css deleted file mode 100644 index bffe35774..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.id-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.html b/frontend/src/app/components/ui-components/filter-fields/id/id.component.html deleted file mode 100644 index 240b86de3..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.html +++ /dev/null @@ -1,10 +0,0 @@ - - {{normalizedLabel()}} - - Value doesn't match pattern. - diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/id/id.component.spec.ts deleted file mode 100644 index ff1383adb..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { IdFilterComponent } from './id.component'; - -describe('IdFilterComponent', () => { - let component: IdFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, IdFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(IdFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/id/id.component.stories.ts deleted file mode 100644 index baacf102a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { IdFilterComponent } from './id.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Id', - component: IdFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/id/id.component.ts b/frontend/src/app/components/ui-components/filter-fields/id/id.component.ts deleted file mode 100644 index b605ca9a5..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/id/id.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-id', - templateUrl: './id.component.html', - styleUrls: ['./id.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class IdFilterComponent extends BaseFilterFieldComponent { - @Input() value: string; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.css b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.css deleted file mode 100644 index abd26e70e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.css +++ /dev/null @@ -1,8 +0,0 @@ -.codemirror-wrapper { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 0; - margin-top: 4px; - margin-bottom: 20px; - overflow: hidden; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html deleted file mode 100644 index dfd3c6bf4..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ normalizedLabel() }} {{ required() ? '*' : '' }} - -
- - -
diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts deleted file mode 100644 index 06bd7ae8a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { JsonEditorFilterComponent } from './json-editor.component'; - -describe('JsonEditorFilterComponent', () => { - let component: JsonEditorFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [JsonEditorFilterComponent, BrowserAnimationsModule], - }) - .overrideComponent(JsonEditorFilterComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(JsonEditorFilterComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('label', 'config'); - component.value = { key: 'value', nested: { data: 123 } }; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize mutableCodeModel with JSON language', () => { - expect(component.mutableCodeModel).toBeDefined(); - expect((component.mutableCodeModel as any).language).toBe('json'); - }); - - it('should set URI based on label', () => { - expect((component.mutableCodeModel as any).uri).toBe('config.json'); - }); - - it('should stringify value with formatting', () => { - const modelValue = (component.mutableCodeModel as any).value; - expect(modelValue).toContain('"key": "value"'); - expect(modelValue).toContain('"nested"'); - }); - - it('should have correct code editor options', () => { - expect(component.codeEditorOptions.minimap.enabled).toBe(false); - expect(component.codeEditorOptions.automaticLayout).toBe(true); - expect(component.codeEditorOptions.scrollBeyondLastLine).toBe(false); - expect(component.codeEditorOptions.wordWrap).toBe('on'); - }); - - it('should handle null value', () => { - component.value = null; - component.ngOnInit(); - // JSON.stringify(null) returns "null", fallback to '{}' only for undefined - expect((component.mutableCodeModel as any).value).toBe('null'); - }); - - it('should handle undefined value with fallback', () => { - component.value = undefined; - component.ngOnInit(); - // undefined is falsy so falls back to '{}' - expect((component.mutableCodeModel as any).value).toBe('{}'); - }); - - it('should normalize label from base class', () => { - fixture.componentRef.setInput('label', 'user_config_data'); - expect(component.normalizedLabel()).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.stories.ts deleted file mode 100644 index f5ac82a02..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { JsonEditorFilterComponent } from './json-editor.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'json', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/JsonEditor', - component: JsonEditorFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: { key: 'value' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts deleted file mode 100644 index 8c9ce4098..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/json-editor/json-editor.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-json-editor', - templateUrl: './json-editor.component.html', - styleUrls: ['./json-editor.component.css'], - imports: [CommonModule, FormsModule, CodeEditorModule], -}) -export class JsonEditorFilterComponent extends BaseFilterFieldComponent { - @Input() value: Object; - - public mutableCodeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - - ngOnInit(): void { - this.mutableCodeModel = { - language: 'json', - uri: `${this.label()}.json`, - value: JSON.stringify(this.value, undefined, 4) || '{}', - }; - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.css b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.css deleted file mode 100644 index ea6057220..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.form-field { - width: 100%; -} - -.form-textarea { - resize: none; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html deleted file mode 100644 index 0c2552d6b..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.html +++ /dev/null @@ -1,10 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.spec.ts deleted file mode 100644 index 840f4901a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { LongTextFilterComponent } from './long-text.component'; - -describe('LongTextFilterComponent', () => { - let component: LongTextFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LongTextFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LongTextFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.stories.ts deleted file mode 100644 index cd1b42d2a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LongTextFilterComponent } from './long-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/LongText', - component: LongTextFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts deleted file mode 100644 index 2dfadab93..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/long-text/long-text.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-long-text', - templateUrl: './long-text.component.html', - styleUrls: ['./long-text.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class LongTextFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - static type = 'text'; - public rowsCount: string; - - ngOnInit(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - this.rowsCount = ws.widget_params.rows; - } else { - this.rowsCount = '4'; - } - } - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.css b/frontend/src/app/components/ui-components/filter-fields/number/number.component.css deleted file mode 100644 index 957c7d47c..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.number-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.html b/frontend/src/app/components/ui-components/filter-fields/number/number.component.html deleted file mode 100644 index 349ad3fb4..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/number/number.component.spec.ts deleted file mode 100644 index aa837dc76..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NumberFilterComponent } from './number.component'; - -describe('NumberFilterComponent', () => { - let component: NumberFilterComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [NumberFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumberFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/number/number.component.stories.ts deleted file mode 100644 index 8623fbd61..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { NumberFilterComponent } from './number.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Number', - component: NumberFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 42, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts b/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts deleted file mode 100644 index c9d2fcceb..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/number/number.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-number', - templateUrl: './number.component.html', - styleUrls: ['./number.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class NumberFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: number; - @ViewChild('inputElement') inputElement: ElementRef; - - static type = 'number'; - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.css b/frontend/src/app/components/ui-components/filter-fields/password/password.component.css deleted file mode 100644 index 1796f8a97..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.password-form-field { - width: 100%; -} - -.password-checkbox { - margin-bottom: 20px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.html b/frontend/src/app/components/ui-components/filter-fields/password/password.component.html deleted file mode 100644 index d34442400..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.html +++ /dev/null @@ -1,14 +0,0 @@ - - {{normalizedLabel()}} - - To keep password the same keep this field blank. - - - Clear password - diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/password/password.component.spec.ts deleted file mode 100644 index 313eedcf6..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { PasswordFilterComponent } from './password.component'; - -describe('PasswordFilterComponent', () => { - let component: PasswordFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PasswordFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should send onChange event with new null value if user clear password', () => { - component.clearPassword = true; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onClearPasswordChange(); - expect(event).toHaveBeenCalledWith(null); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/password/password.component.stories.ts deleted file mode 100644 index dcfef2e4d..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PasswordFilterComponent } from './password.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Password', - component: PasswordFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts b/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts deleted file mode 100644 index 3bd613f27..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/password/password.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-password', - templateUrl: './password.component.html', - styleUrls: ['./password.component.css'], - imports: [CommonModule, FormsModule, MatCheckboxModule, MatFormFieldModule, MatInputModule], -}) -export class PasswordFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - public clearPassword: boolean; - - ngOnInit(): void { - if (this.value === '***') this.value = ''; - this.onFieldChange.emit(this.value); - } - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } - - onClearPasswordChange() { - if (this.clearPassword) this.onFieldChange.emit(null); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css deleted file mode 100644 index ec467bf1b..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.css +++ /dev/null @@ -1,35 +0,0 @@ -.filter-row { - display: flex; - gap: 8px; - align-items: flex-start; - width: 100%; -} - -.comparator-field { - flex: 0 0 auto; - min-width: 150px; -} - -.value-field { - flex: 1; -} - -.country-flag { - margin-right: 8px; - font-size: 16px; -} - -.country-name { - flex: 1; -} - -.country-dial-code { - margin-left: 8px; - opacity: 0.7; - font-size: 12px; -} - -mat-option { - display: flex; - align-items: center; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html deleted file mode 100644 index d0308d4a3..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.html +++ /dev/null @@ -1,37 +0,0 @@ -
- - - equal - country code - starts with - contains - is empty - - - - @if (filterMode === 'country') { - - {{normalizedLabel()}} (country) - - - @for (country of filteredCountries | async; track country.code) { - - {{country.flag}} - {{country.name}} - ({{country.dialCode}}) - - } - - - } @else if (filterMode !== 'empty') { - - {{normalizedLabel()}} - - - } -
diff --git a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts deleted file mode 100644 index 836e75302..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/phone/phone.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, Input, OnInit } from '@angular/core'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { Observable } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; -import { COUNTRIES, Country, getCountryFlag } from '../../../../consts/countries'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -interface CountryWithFlag extends Country { - flag: string; -} - -const COUNTRIES_WITH_FLAGS: CountryWithFlag[] = COUNTRIES.filter((country) => country.dialCode).map((country) => ({ - ...country, - flag: getCountryFlag(country.code), -})); - -@Component({ - selector: 'app-filter-phone', - templateUrl: './phone.component.html', - styleUrls: ['./phone.component.css'], - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - MatFormFieldModule, - MatAutocompleteModule, - MatInputModule, - MatSelectModule, - ], -}) -export class PhoneFilterComponent extends BaseFilterFieldComponent implements OnInit, AfterViewInit { - @Input() value: string; - - public filterMode: string = 'eq'; - public countries: CountryWithFlag[] = COUNTRIES_WITH_FLAGS; - public countryControl = new FormControl(''); - public filteredCountries: Observable; - - getCountryFlag = getCountryFlag; - - ngOnInit(): void { - this.setupAutocomplete(); - - if (this.value) { - const country = this.countries.find((c) => c.dialCode === this.value); - if (country) { - this.filterMode = 'country'; - this.countryControl.setValue(country); - } - } - } - - ngAfterViewInit(): void { - if (this.filterMode !== 'eq') { - this.onComparatorChange.emit(this.filterMode === 'country' ? 'startswith' : this.filterMode); - } - } - - onFilterModeChange(mode: string): void { - this.filterMode = mode; - - if (mode === 'country') { - const selected = this.countryControl.value; - this.value = typeof selected === 'object' && selected ? selected.dialCode : ''; - this.onComparatorChange.emit('startswith'); - } else if (mode === 'empty') { - this.value = ''; - this.onComparatorChange.emit('empty'); - } else { - this.onComparatorChange.emit(mode); - } - - this.onFieldChange.emit(this.value); - } - - onCountrySelected(selectedCountry: CountryWithFlag): void { - this.value = selectedCountry.dialCode; - this.onFieldChange.emit(this.value); - this.onComparatorChange.emit('startswith'); - } - - onValueChange(text: string): void { - this.value = text; - this.onFieldChange.emit(this.value); - this.onComparatorChange.emit(this.filterMode); - } - - displayFn(country: CountryWithFlag): string { - return country ? `${country.flag} ${country.name} (${country.dialCode})` : ''; - } - - private setupAutocomplete(): void { - this.filteredCountries = this.countryControl.valueChanges.pipe( - startWith(''), - map((value) => this._filter(typeof value === 'string' ? value : value?.name || '')), - ); - } - - private _filter(value: string): CountryWithFlag[] { - const filterValue = value.toLowerCase(); - return this.countries.filter( - (country) => - country.name.toLowerCase().includes(filterValue) || - country.code.toLowerCase().includes(filterValue) || - country.dialCode?.includes(filterValue), - ); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.css b/frontend/src/app/components/ui-components/filter-fields/point/point.component.css deleted file mode 100644 index 7ab52d4e1..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.css +++ /dev/null @@ -1,6 +0,0 @@ -.mat-mdc-form-field { - max-width: 260px; - min-width: 150px; - width: 48vw; - display: block; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.html b/frontend/src/app/components/ui-components/filter-fields/point/point.component.html deleted file mode 100644 index 0cfdc9b76..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - {{normalizedLabel()}} X coordinate - - - - {{normalizedLabel()}} Y coordinate - - diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/point/point.component.spec.ts deleted file mode 100644 index 431927de9..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { PointFilterComponent } from './point.component'; - -describe('PointFilterComponent', () => { - let component: PointFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PointFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PointFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/point/point.component.stories.ts deleted file mode 100644 index d3a2ce925..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PointFilterComponent } from './point.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'point', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Point', - component: PointFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '(40.7128, -74.0060)', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/point/point.component.ts b/frontend/src/app/components/ui-components/filter-fields/point/point.component.ts deleted file mode 100644 index 4d9969d01..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/point/point.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-point', - templateUrl: './point.component.html', - styleUrls: ['./point.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class PointFilterComponent extends BaseFilterFieldComponent { - @Input() value; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.css b/frontend/src/app/components/ui-components/filter-fields/select/select.component.css deleted file mode 100644 index 828b119a7..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.select-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.html b/frontend/src/app/components/ui-components/filter-fields/select/select.component.html deleted file mode 100644 index 9a56fd5f6..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - {{normalizedLabel()}} - - @for (option of options; track option.value) { - {{option.label}} - } - - diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts deleted file mode 100644 index 67f0678dc..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { SelectFilterComponent } from './select.component'; - -describe('SelectFilterComponent', () => { - let component: SelectFilterComponent; - let fixture: ComponentFixture; - - const baseStructure = { - column_name: 'status', - column_default: null, - data_type: 'enum', - data_type_params: ['active', 'pending', 'archived'], - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: null, - }; - - const nullableStructure = { - ...baseStructure, - allow_null: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SelectFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SelectFilterComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should populate options from structure.data_type_params', () => { - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - expect(component.options).toEqual([ - { value: 'active', label: 'active' }, - { value: 'pending', label: 'pending' }, - { value: 'archived', label: 'archived' }, - ]); - }); - - it('should prepend null option when allow_null is true', () => { - fixture.componentRef.setInput('structure', nullableStructure); - component.ngOnInit(); - - expect(component.options[0]).toEqual({ value: null, label: '' }); - }); - - it('should start with empty selectedValues when no value is provided', () => { - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - expect(component.selectedValues).toEqual([]); - }); - - it('should restore selectedValues from a scalar value', () => { - component.value = 'active'; - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - expect(component.selectedValues).toEqual(['active']); - }); - - it('should restore selectedValues from a null value', () => { - component.value = null; - fixture.componentRef.setInput('structure', nullableStructure); - component.ngOnInit(); - - expect(component.selectedValues).toEqual([null]); - }); - - it('should restore selectedValues from an array value', () => { - component.value = ['active', 'pending']; - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - expect(component.selectedValues).toEqual(['active', 'pending']); - }); - - it('should emit initial eq comparator after view init', () => { - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', baseStructure); - fixture.detectChanges(); - - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should emit in comparator with array when more than one value is selected', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - component.onSelectionChange(['active', 'pending']); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(['active', 'pending']); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('in'); - }); - - it('should emit eq comparator with scalar when exactly one value is selected', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - component.onSelectionChange(['active']); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith('active'); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should emit eq comparator with null when null is the only selected value', () => { - vi.spyOn(component.onFieldChange, 'emit'); - vi.spyOn(component.onComparatorChange, 'emit'); - fixture.componentRef.setInput('structure', nullableStructure); - component.ngOnInit(); - - component.onSelectionChange([null]); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); - expect(component.onComparatorChange.emit).toHaveBeenCalledWith('eq'); - }); - - it('should emit undefined when selection is cleared', () => { - vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('structure', baseStructure); - component.ngOnInit(); - - component.onSelectionChange([]); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(undefined); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/select/select.component.stories.ts deleted file mode 100644 index 642694e47..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.stories.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { SelectFilterComponent } from './select.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'enum', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, - data_type_params: ['option1', 'option2', 'option3'], -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Select', - component: SelectFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'option1', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts b/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts deleted file mode 100644 index a17995e5a..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/select/select.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-select', - templateUrl: './select.component.html', - styleUrls: ['./select.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class SelectFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: string | (string | null)[] | null; - - public options: { value: string | null; label: string }[] = []; - public selectedValues: (string | null)[] = []; - - originalOrder = () => { - return 0; - }; - - ngOnInit(): void { - const ws = this.widgetStructure(); - const struct = this.structure(); - if (ws) { - this.options = ws.widget_params.options; - if (ws.widget_params.allow_null) { - this.options = [{ value: null, label: '' }, ...this.options]; - } - } else if (struct) { - this.options = struct.data_type_params.map((option) => { - return { value: option, label: option }; - }); - if (struct.allow_null) { - this.options = [{ value: null, label: '' }, ...this.options]; - } - } - - if (Array.isArray(this.value)) { - this.selectedValues = [...this.value]; - } else if (this.value === undefined) { - this.selectedValues = []; - } else { - this.selectedValues = [this.value]; - } - } - - ngAfterViewInit(): void { - this.onComparatorChange.emit('eq'); - } - - onSelectionChange(values: (string | null)[]): void { - this.selectedValues = values; - - if (!values || values.length === 0) { - this.onFieldChange.emit(undefined); - this.onComparatorChange.emit('eq'); - return; - } - - if (values.length === 1) { - this.onFieldChange.emit(values[0]); - this.onComparatorChange.emit('eq'); - return; - } - - this.onFieldChange.emit(values); - this.onComparatorChange.emit('in'); - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.css b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html deleted file mode 100644 index c5faf2630..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.html +++ /dev/null @@ -1,4 +0,0 @@ -
- {{ normalizedLabel() }} - {{ value }} -
diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.spec.ts deleted file mode 100644 index 4c082e27f..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { StaticTextFilterComponent } from './static-text.component'; - -describe('StaticTextFilterComponent', () => { - let component: StaticTextFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StaticTextFilterComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(StaticTextFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.stories.ts deleted file mode 100644 index 29edbffc6..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { StaticTextFilterComponent } from './static-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/StaticText', - component: StaticTextFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.ts b/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.ts deleted file mode 100644 index d82130d19..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/static-text/static-text.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component, Input } from '@angular/core'; - -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-static-text', - templateUrl: './static-text.component.html', - styleUrls: ['./static-text.component.css'], -}) -export class StaticTextFilterComponent extends BaseFilterFieldComponent { - @Input() value: string; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.css b/frontend/src/app/components/ui-components/filter-fields/text/text.component.css deleted file mode 100644 index 17b3101cc..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.text-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.html b/frontend/src/app/components/ui-components/filter-fields/text/text.component.html deleted file mode 100644 index 2208c5cf9..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/text/text.component.spec.ts deleted file mode 100644 index a7755bc05..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TextFilterComponent } from './text.component'; - -describe('TextFilterComponent', () => { - let component: TextFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TextFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/text/text.component.stories.ts deleted file mode 100644 index c3a978087..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TextFilterComponent } from './text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Text', - component: TextFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts b/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts deleted file mode 100644 index d46ee28bc..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/text/text.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-text', - templateUrl: './text.component.html', - styleUrls: ['./text.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class TextFilterComponent extends BaseFilterFieldComponent implements AfterViewInit { - @Input() value: string; - @ViewChild('inputElement') inputElement: ElementRef; - - static type = 'text'; - - ngAfterViewInit(): void { - if (this.autofocus() && this.inputElement) { - setTimeout(() => { - this.inputElement.nativeElement.focus(); - }, 100); - } - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.css b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.css deleted file mode 100644 index 8b6934468..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.css +++ /dev/null @@ -1,13 +0,0 @@ -.feild-set-6 { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-column-gap: 16px; -} - -::ng-deep .interval-form-field .mat-mdc-form-field-infix { - width: auto !important; -} - -.label { - grid-column: 1 / span 3; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html deleted file mode 100644 index e48f18bed..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
- {{normalizedLabel()}} * - - years - - - - months - - - - days - - - - hours - - - - minutes - - - - seconds - - -
diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.spec.ts deleted file mode 100644 index c63726e00..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TimeIntervalFilterComponent } from './time-interval.component'; - -describe('TimeIntervalFilterComponent', () => { - let component: TimeIntervalFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeIntervalFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeIntervalFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.stories.ts deleted file mode 100644 index 4d1c27bd0..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeIntervalFilterComponent } from './time-interval.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'interval', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/TimeInterval', - component: TimeIntervalFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '01:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts deleted file mode 100644 index 14ea6fe93..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time-interval/time-interval.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { CommonModule } from '@angular/common'; - -import { Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import * as pgInterval from 'postgres-interval'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-time-interval', - templateUrl: './time-interval.component.html', - styleUrls: ['./time-interval.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class TimeIntervalFilterComponent extends BaseFilterFieldComponent { - @Input() value; - static type = 'number'; - - public interval = { - years: 0, - months: 0, - days: 0, - hours: 0, - minutes: 0, - seconds: 0, - milliseconds: 0, - }; - - ngOnInit(): void { - if (this.value) this.interval = this.parseIntervalString(this.value); - } - - onInputChange() { - // @ts-expect-error - const currentInterval = pgInterval.prototype.toPostgres.call(this.interval); - this.onFieldChange.emit(currentInterval); - } - - parseIntervalString(input) { - const units = { - year: 'years', - years: 'years', - month: 'months', - months: 'months', - day: 'days', - days: 'days', - hour: 'hours', - hours: 'hours', - minute: 'minutes', - minutes: 'minutes', - second: 'seconds', - seconds: 'seconds', - millisecond: 'milliseconds', - milliseconds: 'milliseconds', - }; - - const result = { - years: 0, - months: 0, - days: 0, - hours: 0, - minutes: 0, - seconds: 0, - milliseconds: 0, - }; - - const regex = /(\d+(?:\.\d+)?)\s*(years?|months?|days?|hours?|minutes?|seconds?|milliseconds?)/gi; - - let match; - while ((match = regex.exec(input)) !== null) { - const value = parseFloat(match[1]); - const unit = match[2].toLowerCase(); - const key = units[unit]; - - if (key) result[key] += value; - } - - return result; - } -} diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.css b/frontend/src/app/components/ui-components/filter-fields/time/time.component.css deleted file mode 100644 index 935f11420..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.time-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.html b/frontend/src/app/components/ui-components/filter-fields/time/time.component.html deleted file mode 100644 index 44b2b29f5..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/time/time.component.spec.ts deleted file mode 100644 index 529593cec..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TimeFilterComponent } from './time.component'; - -describe('TimeFilterComponent', () => { - let component: TimeFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/time/time.component.stories.ts deleted file mode 100644 index ec7f582c5..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeFilterComponent } from './time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'time', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Time', - component: TimeFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '10:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/time/time.component.ts b/frontend/src/app/components/ui-components/filter-fields/time/time.component.ts deleted file mode 100644 index e09cc6723..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/time/time.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -@Component({ - selector: 'app-filter-time', - templateUrl: './time.component.html', - styleUrls: ['./time.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class TimeFilterComponent extends BaseFilterFieldComponent { - @Input() value; - static type = 'datetime'; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css deleted file mode 100644 index 388e7c15e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.timezone-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html deleted file mode 100644 index b776b2e7e..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - {{normalizedLabel()}} - - - {{timezone.label}} - - - diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts deleted file mode 100644 index 591133b92..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { TimezoneFilterComponent } from './timezone.component'; - -describe('TimezoneFilterComponent', () => { - let component: TimezoneFilterComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimezoneFilterComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimezoneFilterComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should populate timezones using Intl API', () => { - expect(component.timezones.length).toBeGreaterThan(0); - }); - - it('should include timezone offset in labels', () => { - const timezone = component.timezones.find((tz) => tz.value === 'Europe/London'); - expect(timezone).toBeDefined(); - expect(timezone.label).toContain('UTC'); - }); - - it('should emit value on change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - const testValue = 'Asia/Tokyo'; - component.value = testValue; - component.onFieldChange.emit(testValue); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(testValue); - }); - - it('should add null option when allow_null is true', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { allow_null: true }, - }); - component.ngOnInit(); - const nullOption = component.timezones.find((tz) => tz.value === null); - expect(nullOption).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.stories.ts b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.stories.ts deleted file mode 100644 index ad189b6d0..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimezoneFilterComponent } from './timezone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Filter Fields/Timezone', - component: TimezoneFilterComponent, - args: { - key: 'field_name', - label: 'Field Label', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'UTC', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts deleted file mode 100644 index 5ea9f120f..000000000 --- a/frontend/src/app/components/ui-components/filter-fields/timezone/timezone.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, Input } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; - -import { BaseFilterFieldComponent } from '../base-filter-field/base-filter-field.component'; - -function getTimezoneOffset(timezone: string): string { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset', - }); - - const parts = formatter.formatToParts(now); - const offsetPart = parts.find((part) => part.type === 'timeZoneName'); - - if (offsetPart?.value.includes('GMT')) { - const offset = offsetPart.value.replace('GMT', ''); - return offset === '' ? '+00:00' : offset; - } - - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); - const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; - const hours = Math.floor(Math.abs(offsetMinutes) / 60); - const minutes = Math.abs(offsetMinutes) % 60; - const sign = offsetMinutes >= 0 ? '+' : '-'; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - } catch (_error) { - return ''; - } -} - -const BASE_TIMEZONES: { value: string; label: string }[] = Intl.supportedValuesOf('timeZone') - .map((tz) => ({ - value: tz, - label: `${tz} (UTC${getTimezoneOffset(tz)})`, - })) - .sort((a, b) => a.value.localeCompare(b.value)); - -@Component({ - selector: 'app-filter-timezone', - imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], - templateUrl: './timezone.component.html', - styleUrls: ['./timezone.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class TimezoneFilterComponent extends BaseFilterFieldComponent { - @Input() value: string; - - public timezones: { value: string; label: string }[] = []; - - originalOrder = () => { - return 0; - }; - - static type = 'timezone'; - - ngOnInit(): void { - const ws = this.widgetStructure(); - const struct = this.structure(); - if (ws?.widget_params?.allow_null || struct?.allow_null) { - this.timezones = [{ value: null, label: '' }, ...BASE_TIMEZONES]; - } else { - this.timezones = BASE_TIMEZONES; - } - } -} diff --git a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.css b/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.css deleted file mode 100644 index 5d79bd9c7..000000000 --- a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.css +++ /dev/null @@ -1,81 +0,0 @@ -.anchor-icon-button { - background-color: var(--bg-color); -} - -@media (prefers-color-scheme: dark) { - .anchor-icon-button { - --bg-color: #292929; - } -} - -@media (prefers-color-scheme: light) { - .anchor-icon-button { - --bg-color: var(--color-primaryPalette-100); - } -} - -.icons-menu { - display: flex; - flex-direction: column; - padding: 0 16px; -} - -.icons-menu__header { - display: flex; - align-items: center; - justify-content: space-between; - color: var(--mat-sidenav-content-text-color); - margin-top: 8px; -} - -.icons-list { - list-style: none; - display: grid; - grid-template-columns: repeat(5, 40px); - grid-gap: 4px; - justify-content: space-between; - margin-top: 8px; - margin-bottom: 12px; -} - -.icon-button { - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - color: var(--mat-sidenav-content-text-color); - height: 40px; - width: 40px; -} - -.icon-radio-button:checked + ::ng-deep.icon-button { - background-color: rgba(0, 0, 0, 0.16); -} - -.custom-icon-box { - position: relative; -} - -.apply-icon-button { - position: absolute; - top: 0; - right: 8px; - align-self: flex-end; - margin-top: 4px; -} - -.icon-input__hint { - color: rgba(0, 0, 0, 0.64); -} - -@media (prefers-color-scheme: dark) { - .icon-input__hint { - color: rgba(255, 255, 255, 0.64); - } -} - -.google-icons-link { - color: var(--color-primaryPalette-500); - font-weight: 600; - text-decoration: none; -} diff --git a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.html b/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.html deleted file mode 100644 index 08fbe63de..000000000 --- a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.html +++ /dev/null @@ -1,52 +0,0 @@ - - -
-
- Choose an icon - -
-
    -
  • - -
  • -
-
- - Add another icon - - - Copy "Icon name" from here - - - -
- -
-
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.spec.ts b/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.spec.ts deleted file mode 100644 index 9f55c4bcc..000000000 --- a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatMenuModule } from '@angular/material/menu'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { IconPickerComponent } from './icon-picker.component'; - -describe('IconPickerComponent', () => { - let component: IconPickerComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatMenuModule, IconPickerComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(IconPickerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.ts b/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.ts deleted file mode 100644 index a3fa6b771..000000000 --- a/frontend/src/app/components/ui-components/icon-picker/icon-picker.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-icon-picker', - templateUrl: './icon-picker.component.html', - styleUrls: ['./icon-picker.component.css'], - imports: [ - CommonModule, - MatMenuModule, - MatIconModule, - FormsModule, - MatTooltipModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - ], -}) -export class IconPickerComponent { - @Input() resetButtonShown: boolean; - @Input() icon: string; - @Input() defaultIcons: Array; - @Input() tooltip: string; - @Output() onFieldChange = new EventEmitter(); - @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; - - public customIcon: string = ''; - - constructor() { - console.log(this.resetButtonShown, 'resetButtonShown'); - } - - ngOnInit() { - this.customIcon = this.icon || ''; - } - - applyIcon() { - this.onFieldChange.emit(this.customIcon); - this.trigger.closeMenu(); - } - - resetIcon() { - this.onFieldChange.emit(null); - this.trigger.closeMenu(); - } -} diff --git a/frontend/src/app/components/ui-components/icon-picker/icon-picker.stories.ts b/frontend/src/app/components/ui-components/icon-picker/icon-picker.stories.ts deleted file mode 100644 index e9ea73d97..000000000 --- a/frontend/src/app/components/ui-components/icon-picker/icon-picker.stories.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { IconPickerComponent } from './icon-picker.component'; - -const meta: Meta = { - title: 'UI/IconPicker', - component: IconPickerComponent, - args: { - icon: 'database', - defaultIcons: ['database', 'table_chart', 'settings', 'person', 'lock', 'vpn_key'], - tooltip: 'Choose an icon', - resetButtonShown: true, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const WithoutReset: Story = { - args: { resetButtonShown: false }, -}; - -export const NoIcon: Story = { - args: { icon: '', tooltip: 'Select icon' }, -}; diff --git a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.css b/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.css deleted file mode 100644 index 677b50f87..000000000 --- a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.ip-copy-button { - background: transparent; - border: none; - color: var(--mat-sidenav-content-text-color); - font-family: monospace; - padding: 2px 4px; -} diff --git a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.html b/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.html deleted file mode 100644 index 0c077626a..000000000 --- a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.spec.ts b/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.spec.ts deleted file mode 100644 index 0db053015..000000000 --- a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; - -import { IpAddressButtonComponent } from './ip-address-button.component'; - -describe('IpAddressButtonComponent', () => { - let component: IpAddressButtonComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, IpAddressButtonComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(IpAddressButtonComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.ts b/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.ts deleted file mode 100644 index bc93d5296..000000000 --- a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; -import { Component, inject, input } from '@angular/core'; -import { NotificationsService } from 'src/app/services/notifications.service'; - -@Component({ - selector: 'app-ip-address-button', - templateUrl: './ip-address-button.component.html', - styleUrls: ['./ip-address-button.component.css'], - imports: [CdkCopyToClipboard], -}) -export class IpAddressButtonComponent { - private _notifications = inject(NotificationsService); - - ip = input(''); - - showCopyNotification(message: string) { - this._notifications.showSuccessSnackbar(message); - } -} diff --git a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.stories.ts b/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.stories.ts deleted file mode 100644 index 79dd17255..000000000 --- a/frontend/src/app/components/ui-components/ip-address-button/ip-address-button.stories.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { NotificationsService } from 'src/app/services/notifications.service'; -import { IpAddressButtonComponent } from './ip-address-button.component'; - -const meta: Meta = { - title: 'UI/IPAddressButton', - component: IpAddressButtonComponent, - decorators: [ - applicationConfig({ - providers: [{ provide: NotificationsService, useValue: { showSuccessSnackbar: () => {} } }], - }), - ], - args: { - ip: '192.168.1.100', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const IPv6: Story = { - args: { ip: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' }, -}; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.css b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.html b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.html deleted file mode 100644 index e878758ff..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.html +++ /dev/null @@ -1 +0,0 @@ -

base-row-field works!

diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts deleted file mode 100644 index 5028614d9..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BaseEditFieldComponent } from './base-row-field.component'; - -describe('BaseEditFieldComponent', () => { - let component: BaseEditFieldComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BaseEditFieldComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BaseEditFieldComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should normalize label on init', () => { - fixture.componentRef.setInput('label', 'user_first_name'); - component.ngOnInit(); - expect(component.normalizedLabel()).toBeTruthy(); - }); - - it('should set normalizedLabel from label input', () => { - fixture.componentRef.setInput('label', 'test_field'); - component.ngOnInit(); - expect(component.normalizedLabel()).toBeDefined(); - expect(typeof component.normalizedLabel()).toBe('string'); - }); - - it('should have onFieldChange event emitter', () => { - expect(component.onFieldChange).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts deleted file mode 100644 index f52f42fdf..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/base-row-field/base-row-field.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, input, OnInit, output } from '@angular/core'; -import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; -import { normalizeFieldName } from '../../../../lib/normalize'; - -@Component({ - selector: 'app-base-edit-field', - templateUrl: './base-row-field.component.html', - styleUrl: './base-row-field.component.css', - imports: [CommonModule], -}) -export class BaseEditFieldComponent implements OnInit { - readonly key = input(); - readonly label = input(); - readonly required = input(false); - readonly readonly = input(false); - readonly structure = input(); - readonly disabled = input(false); - readonly widgetStructure = input(); - readonly relations = input(); - - readonly onFieldChange = output(); - readonly onComparatorChange = output(); - - readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.css b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html deleted file mode 100644 index 10de68e46..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {{normalizedLabel()}} - - binary data - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.spec.ts deleted file mode 100644 index 55b1542d8..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BinaryDataCaptionEditComponent } from './binary-data-caption.component'; - -describe('BinaryDataCaptionEditComponent', () => { - let component: BinaryDataCaptionEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryDataCaptionEditComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BinaryDataCaptionEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.stories.ts deleted file mode 100644 index 4a08a4633..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BinaryDataCaptionEditComponent } from './binary-data-caption.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'bytea', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/BinaryDataCaption', - component: BinaryDataCaptionEditComponent, - args: { - key: 'field_name', - label: 'Binary Data Caption Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.ts deleted file mode 100644 index f1048bfd9..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary-data-caption/binary-data-caption.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-static-text', - templateUrl: './binary-data-caption.component.html', - styleUrls: ['./binary-data-caption.component.css'], - imports: [CommonModule], -}) -export class BinaryDataCaptionEditComponent extends BaseEditFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css deleted file mode 100644 index f0f339e4f..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.binary-edit { - width: 100%; - margin-bottom: 24px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html deleted file mode 100644 index 8c77d7136..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.html +++ /dev/null @@ -1,34 +0,0 @@ - - {{ normalizedLabel() }} ({{ encoding() }}) - @switch (encoding()) { - @case ('hex') { - - @if (binaryContent.errors?.isInvalidHex) { - Invalid hex. - } - } - @case ('base64') { - - @if (binaryContent.errors?.isInvalidBase64) { - Invalid base64. - } - } - @case ('ascii') { - - } - } - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts deleted file mode 100644 index 3cefea83c..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BinaryEditComponent } from './binary.component'; - -describe('BinaryEditComponent', () => { - let component: BinaryEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, BinaryEditComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(BinaryEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('parses a server string as char-code-per-byte on init and does not emit', () => { - vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', 'Hello'); - component.ngOnInit(); - expect(component.rawInput).toBe('48656c6c6f'); - expect(component.onFieldChange.emit).not.toHaveBeenCalled(); - }); - - it('parses an incoming Buffer-JSON value into hex on init without emitting', () => { - vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); - component.ngOnInit(); - expect(component.rawInput).toBe('48656c'); - expect(component.onFieldChange.emit).not.toHaveBeenCalled(); - }); - - it('shows empty hex when incoming value is null and does not emit on init', () => { - vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.rawInput).toBe(''); - expect(component.onFieldChange.emit).not.toHaveBeenCalled(); - }); - - it('emits Buffer-JSON when the user types valid hex', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = '48656c6c6f'; - component.onInputChange(); - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ - type: 'Buffer', - data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], - }); - expect(component.isInvalidInput).toBe(false); - }); - - it('emits null when the field is cleared', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = ''; - component.onInputChange(); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(null); - }); - - it('marks invalid and emits raw string when the user types malformed hex', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = 'zz'; - component.onInputChange(); - expect(component.isInvalidInput).toBe(true); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('zz'); - }); - - describe('encoding param', () => { - it('seeds the input as base64 when encoding=base64', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c, 0x6c, 0x6f] }); - component.ngOnInit(); - expect(component.encoding()).toBe('base64'); - expect(component.rawInput).toBe('SGVsbG8='); - }); - - it('emits Buffer-JSON for valid base64', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = 'SGVsbG8='; - component.onInputChange(); - expect(component.isInvalidInput).toBe(false); - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ - type: 'Buffer', - data: [0x48, 0x65, 0x6c, 0x6c, 0x6f], - }); - }); - - it('marks invalid and emits raw when base64 is malformed', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = '!!!bad!!!'; - component.onInputChange(); - expect(component.isInvalidInput).toBe(true); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('!!!bad!!!'); - }); - - it('seeds the input as ascii with non-printable replacement when encoding=ascii', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x00, 0x69] }); - component.ngOnInit(); - expect(component.encoding()).toBe('ascii'); - expect(component.rawInput).toBe('H.i'); - }); - - it('always emits Buffer-JSON for ascii input, never invalid', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); - vi.spyOn(component.onFieldChange, 'emit'); - component.rawInput = 'Hi'; - component.onInputChange(); - expect(component.isInvalidInput).toBe(false); - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ - type: 'Buffer', - data: [0x48, 0x69], - }); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts deleted file mode 100644 index 510fd25ce..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/binary/binary.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, computed, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Base64ValidationDirective } from 'src/app/directives/base64Validator.directive'; -import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; -import { - BinaryBufferJson, - bytesToEncoded, - encodedToBytes, - isBinaryEncoding, - parseBinaryValue, - toBufferJson, -} from 'src/app/lib/binary'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-binary', - templateUrl: './binary.component.html', - styleUrls: ['./binary.component.css'], - imports: [FormsModule, MatFormFieldModule, MatInputModule, HexValidationDirective, Base64ValidationDirective], -}) -export class BinaryEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - - static type = 'file'; - - public rawInput = ''; - public isInvalidInput = false; - - public readonly encoding = computed(() => { - const raw = this.widgetStructure()?.widget_params?.encoding; - return isBinaryEncoding(raw) ? raw : 'hex'; - }); - - ngOnInit(): void { - super.ngOnInit(); - this.rawInput = bytesToEncoded(parseBinaryValue(this.value()), this.encoding()); - } - - onInputChange(): void { - if (!this.rawInput) { - this.isInvalidInput = false; - this.onFieldChange.emit(null); - return; - } - const bytes = encodedToBytes(this.rawInput, this.encoding()); - if (bytes === null) { - this.isInvalidInput = true; - this.onFieldChange.emit(this.rawInput); - return; - } - this.isInvalidInput = false; - this.onFieldChange.emit(toBufferJson(bytes)); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.css b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.css deleted file mode 100644 index cd69ee3a8..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.css +++ /dev/null @@ -1,23 +0,0 @@ -.radio-line { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 20px; -} - -.radio-line ::ng-deep .mat-button-toggle-appearance-standard.mat-button-toggle-checked { - --mat-button-toggle-selected-state-text-color: var(--color-accentedPalette-500-contrast); - --mat-button-toggle-selected-state-background-color: var(--color-accentedPalette-500); -} - -/* .radio-line ::ng-deep .mat-button-toggle-appearance-standard.mat-button-toggle-checked { - --mat-button-toggle-selected-state-text-color: var(--color-primaryPalette-500-contrast); - --mat-button-toggle-selected-state-background-color: var(--color-primaryPalette-500); -} - -@media (prefers-color-scheme: dark) { - .radio-line ::ng-deep .mat-button-toggle-appearance-standard.mat-button-toggle-checked { - --mat-button-toggle-selected-state-text-color: var(--color-whitePalette-500-contrast); - --mat-button-toggle-selected-state-background-color: var(--color-whitePalette-500); - } -} */ diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html deleted file mode 100644 index 208cbc33d..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.html +++ /dev/null @@ -1,26 +0,0 @@ -@if (isRadiogroup) { -
- - - Yes - No - -
-} @else { -
- - - Yes - No - -
-} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts deleted file mode 100644 index b17d63a5f..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BooleanEditComponent } from './boolean.component'; - -describe('BooleanEditComponent', () => { - let component: BooleanEditComponent; - let fixture: ComponentFixture; - - const fakeStructure = { - column_name: 'banned', - column_default: '0', - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 1, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, BooleanEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BooleanEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set value in true when input value contain anything', () => { - fixture.componentRef.setInput('value', 'anything'); - fixture.componentRef.setInput('structure', fakeStructure); - component.ngOnInit(); - - expect(component.value()).toBeTruthy(); - }); - - it('should set value in felse when input value is 0', () => { - fixture.componentRef.setInput('value', 0); - fixture.componentRef.setInput('structure', fakeStructure); - component.ngOnInit(); - - expect(component.value()).toBeFalsy(); - }); - - it('should set value in null when input value is undefined', () => { - fixture.componentRef.setInput('value', undefined); - fixture.componentRef.setInput('structure', fakeStructure); - component.ngOnInit(); - - expect(component.value()).toEqual(null); - }); - - it('should set isRadiogroup in false if allow_null is false', () => { - fixture.componentRef.setInput('value', undefined); - fixture.componentRef.setInput('structure', fakeStructure); - component.ngOnInit(); - - expect(component.isRadiogroup).toEqual(false); - }); - - it('should set isRadiogroup in true if allow_null is true', () => { - fixture.componentRef.setInput('value', undefined); - fixture.componentRef.setInput('structure', { - column_name: 'banned', - column_default: '0', - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, - }); - component.ngOnInit(); - - expect(component.isRadiogroup).toEqual(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.stories.ts deleted file mode 100644 index 7e7525336..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BooleanEditComponent } from './boolean.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'boolean', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Boolean', - component: BooleanEditComponent, - args: { - key: 'field_name', - label: 'Boolean Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: true, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts deleted file mode 100644 index 3445003da..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/boolean/boolean.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { DBtype } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-boolean', - templateUrl: './boolean.component.html', - styleUrls: ['./boolean.component.css'], - imports: [CommonModule, FormsModule, MatButtonToggleModule], -}) -export class BooleanEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public isRadiogroup: boolean; - connectionType: DBtype; - - private _connections = inject(ConnectionsService); - - ngOnInit(): void { - super.ngOnInit(); - this.connectionType = this._connections.currentConnection.type; - - const val = this.value(); - if (val) { - this.value.set(true); - } else if (val === 0 || val === '' || val === false) { - this.value.set(false); - } else { - this.value.set(null); - } - - this.onFieldChange.emit(this.value()); - - let parsedParams = null; - const ws = this.widgetStructure(); - if (ws?.widget_params) { - parsedParams = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - } - - this.isRadiogroup = this.structure()?.allow_null || !!parsedParams?.allow_null; - } - - onToggleChange(optionValue: boolean): void { - if (this.value() === optionValue) { - this.value.set(null); - } else { - this.value.set(optionValue); - } - this.onFieldChange.emit(this.value()); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.css b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.css deleted file mode 100644 index 05e90e456..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.css +++ /dev/null @@ -1,16 +0,0 @@ -.code-editor-box { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 4px; - margin-top: 4px; - margin-bottom: 20px; - /* overflow-y: auto; - resize: vertical; */ - overflow: auto; - resize: both; -} - -.code-editor-box ::ng-deep .ngs-code-editor { - width: 100%; - height: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html deleted file mode 100644 index 6dce1ee7a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ normalizedLabel() }} {{ required() ? '*' : '' }} - -
- - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts deleted file mode 100644 index 08f2fc367..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { CodeEditComponent } from './code.component'; - -describe('CodeEditComponent', () => { - let component: CodeEditComponent; - let fixture: ComponentFixture; - - const mockUiSettingsService = { - isDarkMode: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CodeEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), { provide: UiSettingsService, useValue: mockUiSettingsService }], - }) - .overrideComponent(CodeEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(CodeEditComponent); - component = fixture.componentInstance; - - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - language: 'css', - }, - } as any); - fixture.componentRef.setInput('label', 'styles'); - fixture.componentRef.setInput('value', '.container { display: flex; }'); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize mutableCodeModel with correct language from widget params', () => { - expect(component.mutableCodeModel).toBeDefined(); - expect((component.mutableCodeModel as any).language).toBe('css'); - }); - - it('should set URI based on label', () => { - expect((component.mutableCodeModel as any).uri).toBe('styles.json'); - }); - - it('should set value from input', () => { - expect((component.mutableCodeModel as any).value).toBe('.container { display: flex; }'); - }); - - it('should use editor theme from UiSettingsService', () => { - expect(component.codeEditorTheme).toBe('vs-dark'); - }); - - it('should have correct code editor options', () => { - expect(component.codeEditorOptions.minimap.enabled).toBe(false); - expect(component.codeEditorOptions.automaticLayout).toBe(true); - expect(component.codeEditorOptions.scrollBeyondLastLine).toBe(false); - expect(component.codeEditorOptions.wordWrap).toBe('on'); - }); - - it('should support different languages', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - language: 'javascript', - }, - } as any); - fixture.componentRef.setInput('label', 'script'); - fixture.componentRef.setInput('value', 'console.log("hello");'); - component.ngOnInit(); - - expect((component.mutableCodeModel as any).language).toBe('javascript'); - }); - - it('should normalize label from base class', () => { - fixture.componentRef.setInput('label', 'custom_styles'); - component.ngOnInit(); - expect(component.normalizedLabel()).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.stories.ts deleted file mode 100644 index 94cbbdf3a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CodeEditComponent } from './code.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: { language: 'javascript' }, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Code', - component: CodeEditComponent, - args: { - key: 'field_name', - label: 'Code Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'console.log("hello")', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts deleted file mode 100644 index 692293453..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/code/code.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-code', - templateUrl: './code.component.html', - styleUrl: './code.component.css', - imports: [CommonModule, CodeEditorModule], -}) -export class CodeEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - private _uiSettings = inject(UiSettingsService); - - public mutableCodeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme = 'vs-dark'; - - ngOnInit(): void { - super.ngOnInit(); - this.mutableCodeModel = { - language: `${this.widgetStructure().widget_params.language}`, - uri: `${this.label()}.json`, - value: this.value(), - }; - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.css b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.css deleted file mode 100644 index 88dc71654..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.css +++ /dev/null @@ -1,30 +0,0 @@ -.color-edit-container { - width: 100%; -} - -.color-form-field { - width: 100%; -} - -.color-picker-container { - display: flex; - align-items: center; - margin-left: 8px; -} - -.color-picker-input { - width: 32px; - height: 32px; - border: none; - border-radius: 4px; - cursor: pointer; - outline: none; - background: none; - padding: 0; - margin-right: 8px; -} - -.color-picker-input:disabled { - cursor: not-allowed; - opacity: 0.6; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html deleted file mode 100644 index 6f25f812b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.html +++ /dev/null @@ -1,18 +0,0 @@ -
- - {{normalizedLabel()}} - -
- -
-
-
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts deleted file mode 100644 index 282e302f7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { ColorEditComponent } from './color.component'; - -describe('ColorEditComponent', () => { - let component: ColorEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ColorEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ColorEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should return false for isValidColor when value is empty', () => { - fixture.componentRef.setInput('value', ''); - expect(component.isValidColor).toBe(false); - }); - - it('should return false for isValidColor when value is null', () => { - fixture.componentRef.setInput('value', null); - expect(component.isValidColor).toBe(false); - }); - - it('should return true for isValidColor when value is a valid hex color', () => { - fixture.componentRef.setInput('value', '#ff0000'); - expect(component.isValidColor).toBe(true); - }); - - it('should return true for isValidColor when value is a hex color without hash', () => { - fixture.componentRef.setInput('value', 'ff0000'); - expect(component.isValidColor).toBe(true); - }); - - it('should return true for isValidColor when value is a valid rgb color', () => { - fixture.componentRef.setInput('value', 'rgb(255, 0, 0)'); - expect(component.isValidColor).toBe(true); - }); - - it('should return false for isValidColor when value is invalid', () => { - fixture.componentRef.setInput('value', 'notacolor'); - expect(component.isValidColor).toBe(false); - }); - - it('should return normalized hex for color picker', () => { - fixture.componentRef.setInput('value', '#ff0000'); - expect(component.normalizedColorForPicker.toLowerCase()).toBe('#ff0000'); - }); - - it('should return #000000 for invalid color in normalizedColorForPicker', () => { - fixture.componentRef.setInput('value', 'invalid'); - expect(component.normalizedColorForPicker).toBe('#000000'); - }); - - it('should return hex_hash format by default in formattedColorValue', () => { - fixture.componentRef.setInput('value', 'rgb(255, 0, 0)'); - const result = component.formattedColorValue; - expect(result).toMatch(/^#[A-Fa-f0-9]{6,8}$/); - }); - - it('should return hex without hash when format is hex', () => { - fixture.componentRef.setInput('value', '#ff0000'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'hex' } } as any); - const result = component.formattedColorValue; - expect(result).not.toContain('#'); - }); - - it('should return rgb format when format is rgb', () => { - fixture.componentRef.setInput('value', '#ff0000'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'rgb' } } as any); - const result = component.formattedColorValue; - expect(result).toMatch(/^rgb/); - }); - - it('should handle hsl format and fallback to hex when hsl conversion fails', () => { - fixture.componentRef.setInput('value', '#ff0000'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { format: 'hsl' } } as any); - const result = component.formattedColorValue; - // colorString.get.hsl returns null for hex input, so it falls back to hex - expect(result).toMatch(/^#|^hsl/); - }); - - it('should return original value for formattedColorValue when value is invalid', () => { - fixture.componentRef.setInput('value', 'invalid'); - expect(component.formattedColorValue).toBe('invalid'); - }); - - it('should emit onFieldChange when onTextInputChange is called', () => { - vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', '#ff0000'); - component.onTextInputChange(); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('#ff0000'); - }); - - it('should update value and emit onFieldChange when onColorPickerChange is called', () => { - vi.spyOn(component.onFieldChange, 'emit'); - const event = { target: { value: '#00ff00' } } as any; - component.onColorPickerChange(event); - expect(component.value()).toBeTruthy(); - expect(component.onFieldChange.emit).toHaveBeenCalled(); - }); - - it('should set value directly when picker value cannot be parsed', () => { - vi.spyOn(component.onFieldChange, 'emit'); - const event = { target: { value: '' } } as any; - component.onColorPickerChange(event); - expect(component.value()).toBe(''); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.stories.ts deleted file mode 100644 index 35fddf649..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ColorEditComponent } from './color.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Color', - component: ColorEditComponent, - args: { - key: 'field_name', - label: 'Color Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '#FF5733', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts deleted file mode 100644 index 35700e6de..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/color/color.component.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import colorString from 'color-string'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-color', - templateUrl: './color.component.html', - styleUrls: ['./color.component.css'], - imports: [MatFormFieldModule, MatInputModule, FormsModule], -}) -export class ColorEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - static type = 'color'; - - get isValidColor(): boolean { - const val = this.value(); - if (!val) return false; - return this._parseColor(val) !== null; - } - - get normalizedColorForPicker(): string { - const parsed = this._parseColor(this.value()); - if (parsed) { - const [r, g, b] = parsed.value; - return `#${this._toHex(r)}${this._toHex(g)}${this._toHex(b)}`; - } - return '#000000'; - } - - get formattedColorValue(): string { - const val = this.value(); - const parsed = this._parseColor(val); - if (!parsed) return val; - - const format = this.widgetStructure()?.widget_params?.format || 'hex_hash'; - const [r, g, b, a] = parsed.value; - - switch (format) { - case 'hex': - return colorString.to.hex(r, g, b, a).slice(1); - case 'hex_hash': - return colorString.to.hex(r, g, b, a); - case 'rgb': - return colorString.to.rgb(r, g, b, a); - case 'hsl': { - const hex = colorString.to.hex(r, g, b, a); - const hslParsed = colorString.get.hsl(hex); - if (hslParsed) { - const [h, s, l, alpha] = hslParsed; - return colorString.to.hsl(h, s, l, alpha); - } - return hex; - } - default: - return colorString.to.hex(r, g, b, a); - } - } - - onColorPickerChange(event: Event) { - const target = event.target as HTMLInputElement; - const pickerValue = target.value; - - const parsed = this._parseColor(pickerValue); - if (parsed) { - const format = this.widgetStructure()?.widget_params?.format || 'hex_hash'; - const [r, g, b, a] = parsed.value; - - switch (format) { - case 'hex': - this.value.set(colorString.to.hex(r, g, b, a).slice(1)); - break; - case 'hex_hash': - this.value.set(colorString.to.hex(r, g, b, a)); - break; - case 'rgb': - this.value.set(colorString.to.rgb(r, g, b, a)); - break; - case 'hsl': { - const hex = colorString.to.hex(r, g, b, a); - const hslParsed = colorString.get.hsl(hex); - if (hslParsed) { - const [h, s, l, alpha] = hslParsed; - this.value.set(colorString.to.hsl(h, s, l, alpha)); - } else { - this.value.set(hex); - } - break; - } - default: - this.value.set(colorString.to.hex(r, g, b, a)); - } - } else { - this.value.set(pickerValue); - } - - this.onFieldChange.emit(this.value()); - } - - onTextInputChange() { - this.onFieldChange.emit(this.value()); - } - - private _parseColor(color: string): any { - if (!color) return null; - - const parsed = colorString.get(color); - if (parsed) return parsed; - - if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { - return colorString.get('#' + color); - } - - return null; - } - - private _toHex(n: number): string { - const hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.css b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.css deleted file mode 100644 index cf6260673..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.css +++ /dev/null @@ -1,42 +0,0 @@ -.country-form-field { - width: 100%; -} - -.country-input-container { - display: flex; - align-items: center; - width: 100%; -} - -.country-flag-prefix { - font-size: 20px; - margin-right: 8px; - line-height: 1; -} - -.country-input { - flex: 1; - border: none !important; - outline: none !important; - background: transparent !important; -} - -.country-flag { - margin-right: 8px; - font-size: 16px; -} - -.country-name { - flex: 1; -} - -.country-code { - margin-left: 8px; - opacity: 0.7; - font-size: 12px; -} - -mat-option { - display: flex; - align-items: center; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html deleted file mode 100644 index 596070843..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - {{normalizedLabel()}} -
- @if (selectedCountryFlag() && showFlag()) { - {{selectedCountryFlag()}} - } - -
- - @for (country of filteredCountries(); track country.value) { - - @if (country.flag && showFlag()) { - {{country.flag}} - } - {{country.label}} - @if (country.value) { - ({{country.value}}) - } - - } - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts deleted file mode 100644 index e5d856407..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CountryEditComponent } from './country.component'; - -describe('CountryEditComponent', () => { - let component: CountryEditComponent; - let fixture: ComponentFixture; - - const fakeStructure = { - column_name: 'country', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 2, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CountryEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(CountryEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load countries on init', () => { - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - expect(component.countries.length).toBeGreaterThan(0); - }); - - it('should sort countries alphabetically', () => { - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - const labels = component.countries.map((c) => c.label); - const sorted = [...labels].sort((a, b) => a.localeCompare(b)); - expect(labels).toEqual(sorted); - }); - - it('should prepend null option when allow_null is true', () => { - fixture.componentRef.setInput('structure', { ...fakeStructure, allow_null: true } as any); - component.ngOnInit(); - expect(component.countries[0].value).toBeNull(); - expect(component.countries[0].label).toBe(''); - }); - - it('should not prepend null option when allow_null is false', () => { - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - expect(component.countries[0].value).not.toBeNull(); - }); - - it('should set initial value when value matches a country code', () => { - fixture.componentRef.setInput('value', 'US'); - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - const controlValue = component.countryControl.value; - expect(controlValue).toBeTruthy(); - expect(typeof controlValue).toBe('object'); - expect((controlValue as any).value).toBe('US'); - }); - - it('should not set control value when value does not match any country', () => { - fixture.componentRef.setInput('value', 'XX'); - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - const controlValue = component.countryControl.value; - expect(controlValue).toBe(''); - }); - - it('should emit onFieldChange when a country is selected', () => { - vi.spyOn(component.onFieldChange, 'emit'); - const country = { value: 'FR', label: 'France', flag: '🇫🇷' }; - component.onCountrySelected(country); - expect(component.value()).toBe('FR'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('FR'); - }); - - it('should display label for country object in displayFn', () => { - const result = component.displayFn({ value: 'US', label: 'United States', flag: '🇺🇸' }); - expect(result).toBe('United States'); - }); - - it('should return string as-is in displayFn', () => { - const result = component.displayFn('some text'); - expect(result).toBe('some text'); - }); - - it('should return empty string for falsy input in displayFn', () => { - const result = component.displayFn(null); - expect(result).toBe(''); - }); - - it('should filter countries by label', () => { - fixture.componentRef.setInput('structure', fakeStructure as any); - component.ngOnInit(); - component.countryControl.setValue('united'); - const filtered = component.filteredCountries(); - expect(filtered.length).toBeGreaterThan(0); - filtered.forEach((c) => { - expect(c.label.toLowerCase() + c.value?.toLowerCase()).toContain('united'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.stories.ts deleted file mode 100644 index dd553a303..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CountryEditComponent } from './country.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Country', - component: CountryEditComponent, - args: { - key: 'field_name', - label: 'Country Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'US', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts deleted file mode 100644 index 1a1b62c53..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/country/country.component.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, model } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -interface CountryOption { - value: string | null; - label: string; - flag: string; -} - -@Component({ - selector: 'app-edit-country', - imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule], - templateUrl: './country.component.html', - styleUrls: ['./country.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class CountryEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public countries: CountryOption[] = []; - public countryControl = new FormControl(''); - public selectedCountryFlag = computed(() => { - const value = this._controlValue(); - if (typeof value === 'object' && value !== null) { - return value.flag; - } - return ''; - }); - - public showFlag = computed(() => { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - try { - const params = - typeof ws.widget_params === 'string' - ? JSON.parse(ws.widget_params) - : ws.widget_params; - - if (params.show_flag !== undefined) { - return params.show_flag; - } - } catch (e) { - console.error('Error parsing country widget params:', e); - } - } - return true; - }); - - private _controlValue = toSignal(this.countryControl.valueChanges, { initialValue: '' as CountryOption | string }); - - public filteredCountries = computed(() => { - const value = this._controlValue(); - return this._filter(typeof value === 'string' ? value : value?.label || ''); - }); - - originalOrder = () => { - return 0; - }; - - getCountryFlag = getCountryFlag; - - ngOnInit(): void { - super.ngOnInit(); - this.loadCountries(); - this.setInitialValue(); - } - - onCountrySelected(selectedCountry: CountryOption): void { - this.value.set(selectedCountry.value); - this.onFieldChange.emit(this.value()); - } - - displayFn(country: CountryOption | string): string { - if (!country) return ''; - return typeof country === 'string' ? country : country.label; - } - - private setInitialValue(): void { - if (this.value()) { - const country = this.countries.find((c) => c.value === this.value()); - if (country) { - this.countryControl.setValue(country); - } - } - } - - private _filter(value: string): CountryOption[] { - const filterValue = value.toLowerCase(); - return this.countries.filter( - (country) => - country.label?.toLowerCase().includes(filterValue) || country.value?.toLowerCase().includes(filterValue), - ); - } - - private loadCountries(): void { - this.countries = COUNTRIES.map((country) => ({ - value: country.code, - label: country.name, - flag: getCountryFlag(country.code), - })).toSorted((a, b) => a.label.localeCompare(b.label)); - - const ws = this.widgetStructure(); - if (ws?.widget_params?.allow_null || this.structure()?.allow_null) { - this.countries = [{ value: null, label: '', flag: '' }, ...this.countries]; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.css deleted file mode 100644 index f63bf4c46..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.css +++ /dev/null @@ -1,5 +0,0 @@ -.field-couple { - display: grid; - grid-template-columns: 1fr 1fr; - grid-column-gap: 16px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html deleted file mode 100644 index f6708d890..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
- - {{normalizedLabel()}} (date) - - - - - {{normalizedLabel()}} (time) - - -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts deleted file mode 100644 index 52f4ba963..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { DBtype } from 'src/app/models/connection'; -import { DateTimeEditComponent } from './date-time.component'; - -describe('DateTimeEditComponent', () => { - let component: DateTimeEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, DateTimeEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateTimeEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should prepare date and time for date and time inputs', () => { - fixture.componentRef.setInput('value', '2021-06-26T07:22:00.603'); - component.ngOnInit(); - - expect(component.date).toEqual('2021-06-26'); - expect(component.time).toEqual('07:22:00'); - }); - - it('should send onChange event with new date value if connectionType is not mysql', () => { - component.connectionType = DBtype.Postgres; - component.date = '2021-08-26'; - component.time = '07:22:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onDateChange(); - - expect(event).toHaveBeenCalledWith('2021-08-26T07:22:00Z'); - }); - - it('should send onChange event with new date value if connectionType is mysql', () => { - component.connectionType = DBtype.MySQL; - component.date = '2021-08-26'; - component.time = '07:22:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onDateChange(); - - expect(event).toHaveBeenCalledWith('2021-08-26 07:22:00'); - }); - - it('should send onChange event with new time value if connectionType is not mysql', () => { - component.connectionType = DBtype.Postgres; - component.date = '2021-07-26'; - component.time = '07:20:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onTimeChange(); - - expect(event).toHaveBeenCalledWith('2021-07-26T07:20:00Z'); - }); - - it('should send onChange event with new time value if connectionType is mysql', () => { - component.connectionType = DBtype.MySQL; - component.date = '2021-07-26'; - component.time = '07:20:00'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onTimeChange(); - - expect(event).toHaveBeenCalledWith('2021-07-26 07:20:00'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.stories.ts deleted file mode 100644 index 8c1bb65d3..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateTimeEditComponent } from './date-time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'datetime', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/DateTime', - component: DateTimeEditComponent, - args: { - key: 'field_name', - label: 'DateTime Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '2024-01-15T10:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts deleted file mode 100644 index 39a9da4c2..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date-time/date-time.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { format } from 'date-fns'; -import { DBtype } from 'src/app/models/connection'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-date-time', - templateUrl: './date-time.component.html', - styleUrls: ['./date-time.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class DateTimeEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - static type = 'datetime'; - public date: string; - public time: string; - public connectionType: DBtype; - - private _connections = inject(ConnectionsService); - - ngOnInit(): void { - super.ngOnInit(); - this.connectionType = this._connections.currentConnection.type; - - const val = this.value(); - if (val) { - const datetime = new Date(val); - this.date = format(datetime, 'yyyy-MM-dd'); - this.time = format(datetime, 'HH:mm:ss'); - } - } - - onDateChange() { - if (!this.time) this.time = '00:00:00'; - - let datetime = ''; - if (this.connectionType === DBtype.MySQL) { - datetime = `${this.date} ${this.time}`; - } else { - datetime = `${this.date}T${this.time}Z`; - } - - this.onFieldChange.emit(datetime); - } - - onTimeChange() { - let datetime = ''; - if (this.connectionType === DBtype.MySQL) { - datetime = `${this.date} ${this.time}`; - } else { - datetime = `${this.date}T${this.time}Z`; - } - - this.onFieldChange.emit(datetime); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.css b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.css deleted file mode 100644 index 03fe2b8ac..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.date-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html deleted file mode 100644 index 46faec623..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts deleted file mode 100644 index 585ebc891..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { DateEditComponent } from './date.component'; - -describe('DateEditComponent', () => { - let component: DateEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should prepare date for date input', () => { - fixture.componentRef.setInput('value', '2021-06-26T07:22:00.603Z'); - component.ngOnInit(); - - expect(component.date).toEqual('2021-06-26'); - }); - - it('should remain date undefined if there is no value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - - expect(component.date).not.toBeDefined(); - }); - - it('should send onChange event with new date value', () => { - component.date = '2021-07-26'; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onDateChange(); - expect(event).toHaveBeenCalledWith('2021-07-26'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.stories.ts deleted file mode 100644 index 5256fc595..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateEditComponent } from './date.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'date', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Date', - component: DateEditComponent, - args: { - key: 'field_name', - label: 'Date Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '2024-01-15', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts deleted file mode 100644 index bb9624a60..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/date/date.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { format } from 'date-fns'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-date', - templateUrl: './date.component.html', - styleUrls: ['./date.component.css'], - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule], -}) -export class DateEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - static type = 'datetime'; - public date: string; - - ngOnInit(): void { - super.ngOnInit(); - const val = this.value(); - if (val) { - const datetime = new Date(val); - this.date = format(datetime, 'yyyy-MM-dd'); - } - } - - onDateChange() { - this.onFieldChange.emit(this.date); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.css b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.css deleted file mode 100644 index af9a259e7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.css +++ /dev/null @@ -1,45 +0,0 @@ -.file { - margin-bottom: 24px; -} - -.header { - display: flex; - align-items: center; - margin-bottom: 20px; -} - -.file-type { - margin-right: 20px; -} - -.file-raw-data { - width: 100%; -} - -.file-input { - display: none; -} - -.file-uploader-appearance { - margin-bottom: 20px; -} - -.file-label { - margin-right: 20px; -} - -.file-name { - color: rgba(0, 0, 0, 0.5); - font-style: italic; - margin-right: 20px; -} - -@media (prefers-color-scheme: dark) { - .file-name { - color: rgba(255, 255, 255, 0.5); - } -} - -.upload-btn { - margin-right: 12px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html deleted file mode 100644 index 1e85a3b34..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.html +++ /dev/null @@ -1,78 +0,0 @@ -
-
- {{ normalizedLabel() }} - - @if (widgetStructure() && widgetStructure().widget_params.type) { - @if (initError) { - {{ initError }} - } @else { - {{ widgetStructure().widget_params.type }} - } - } @else { - - - HEX - - - Base64 - - - File - - - } -
- @if (!initError) { - @if (fileType === 'hex') { - - Hex - - @if (hexContent.errors?.isInvalidHex) { - Invalid hex. - } - - } - - @if (fileType === 'base64') { - - Base64 - - @if (base64Content.errors?.isInvalidBase64 || isNotSwitcherActive) { - Invalid base64. - } - - } - - @if (fileType === 'file') { -
- - - {{ value() ? 'File is uploaded.' : 'No file uploaded yet.' }} - - - - @if (hexData) { - - Download file - - } -
- } - } -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts deleted file mode 100644 index 2189df298..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatRadioModule } from '@angular/material/radio'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { FileEditComponent } from './file.component'; - -describe('FileEditComponent', () => { - let component: FileEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, MatRadioModule, FileEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FileEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should default fileType to hex', () => { - expect(component.fileType).toBe('hex'); - }); - - it('should set hexData from value on init when no widgetStructure', () => { - fixture.componentRef.setInput('value', '48656c6c6f' as any); - component.ngOnInit(); - expect(component.hexData).toBe('48656c6c6f'); - }); - - it('should set fileType from widgetStructure on init', () => { - fixture.componentRef.setInput('value', '48656c6c6f' as any); - fixture.componentRef.setInput('widgetStructure', { widget_params: { type: 'base64' } } as any); - component.ngOnInit(); - expect(component.fileType).toBe('base64'); - }); - - it('should convert hex to base64', () => { - component.hexData = '48656c6c6f'; - component.fromHexToBase64(); - expect(component.base64Data).toBe('SGVsbG8='); - }); - - it('should convert base64 to hex', () => { - component.base64Data = 'SGVsbG8='; - component.fromBase64ToHex(); - expect(component.hexData).toBe('48656c6c6f'); - }); - - it('should set isNotSwitcherActive to true on invalid base64', () => { - component.base64Data = '!!!invalid!!!'; - component.fromBase64ToHex(); - expect(component.isNotSwitcherActive).toBe(true); - }); - - it('should emit onFieldChange on hex change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.hexData = '48656c6c6f'; - component.onHexChange(); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); - }); - - it('should convert base64 to hex and emit on base64 change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.base64Data = 'SGVsbG8='; - component.onBase64Change(); - expect(component.hexData).toBe('48656c6c6f'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('48656c6c6f'); - }); - - it('should call convertValue for base64 when hexData exists', () => { - component.hexData = '48656c6c6f'; - component.convertValue('base64' as any); - expect(component.base64Data).toBe('SGVsbG8='); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.stories.ts deleted file mode 100644 index 69e86bee5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { FileEditComponent } from './file.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'bytea', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/File', - component: FileEditComponent, - args: { - key: 'field_name', - label: 'File Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '' as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts deleted file mode 100644 index b99033392..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/file/file.component.ts +++ /dev/null @@ -1,159 +0,0 @@ - -import { Component, inject, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { Base64ValidationDirective } from 'src/app/directives/base64Validator.directive'; -import { HexValidationDirective } from 'src/app/directives/hexValidator.directive'; -import { base64Validation } from 'src/app/validators/base64.validator'; -import { hexValidation } from 'src/app/validators/hex.validator'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -interface Blob { - type: string; - data: []; -} - -enum FileType { - Hex = 'hex', - Base64 = 'base64', - File = 'file', -} - -@Component({ - selector: 'app-edit-file', - templateUrl: './file.component.html', - styleUrls: ['./file.component.css'], - imports: [ - FormsModule, - MatFormFieldModule, - MatInputModule, - MatRadioModule, - MatButtonModule, - HexValidationDirective, - Base64ValidationDirective, - ], -}) -export class FileEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - private sanitazer = inject(DomSanitizer); - - static type = 'file'; - public isNotSwitcherActive; - public fileType: FileType = FileType.Hex; - public hexData; - public base64Data; - public fileData; - public fileURL: SafeUrl; - public initError: string | null = null; - - ngOnInit(): void { - super.ngOnInit(); - const ws = this.widgetStructure(); - if (ws && this.value()) { - this.fileType = ws.widget_params.type; - - if (this.fileType === 'hex') { - this.hexData = this.value(); - //@ts-expect-error - this.initError = hexValidation()({ value: this.hexData }); - this.initError = 'Invalid hex format.'; - } - - if (this.fileType === 'base64') { - this.base64Data = this.value(); - //@ts-expect-error - this.initError = base64Validation()({ value: this.hexData }); - this.initError = 'Invalid base64 format.'; - } - - if (this.fileType === 'file') { - //@ts-expect-error - const blob = new Blob([this.value()]); - this.fileURL = this.sanitazer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); - } - } - - if (this.value()) { - this.hexData = this.value(); - } - } - - convertValue(type: FileType) { - if (this.hexData && type === FileType.Base64) { - this.fromHexToBase64(); - } - - if (this.hexData && type === FileType.File) { - this.fromHexToFile(); - } - } - - onHexChange() { - //@ts-expect-error - this.isNotSwitcherActive = hexValidation()({ value: this.hexData }); - this.onFieldChange.emit(this.hexData); - } - - onBase64Change() { - this.fromBase64ToHex(); - this.onFieldChange.emit(this.hexData); - } - - onFileSelected(event) { - let reader = new FileReader(); - const file: File = event.target.files[0]; - - reader.addEventListener( - 'load', - () => { - this.fromFileToHex(reader); - this.onFieldChange.emit(this.hexData); - }, - false, - ); - - reader.readAsArrayBuffer(file); - } - - fromHexToBase64() { - this.base64Data = btoa( - this.hexData - .match(/\w{2}/g) - .map((a) => String.fromCharCode(parseInt(a, 16))) - .join(''), - ); - } - - fromBase64ToHex() { - try { - const raw = atob(this.base64Data); - let result = ''; - for (let i = 0; i < raw.length; i++) { - const hex = raw.charCodeAt(i).toString(16); - result += hex.length === 2 ? hex : '0' + hex; - } - this.hexData = result; - this.isNotSwitcherActive = false; - } catch (_e) { - this.isNotSwitcherActive = true; - } - } - - fromHexToFile() { - const fileData = new Uint8Array(this.hexData.match(/[\da-f]{2}/gi).map((h) => parseInt(h, 16))); - fileData.buffer; - const blob = new Blob([fileData]); - this.fileURL = this.sanitazer.bypassSecurityTrustUrl(URL.createObjectURL(blob)); - } - - fromFileToHex(reader: FileReader) { - let dataString = reader.result as ArrayBuffer; - - this.hexData = [...new Uint8Array(dataString)].map((b) => b.toString(16).padStart(2, '0')).join(''); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css deleted file mode 100644 index 648d1b63f..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.css +++ /dev/null @@ -1,59 +0,0 @@ -.full-width { - position: relative; - width: 100%; -} - -.loader { - position: absolute; - top: 12px; - right: 12px; -} - -.disabled { - pointer-events: none; -} - -.foreign-key { - display: grid; - grid-template-columns: 1fr 36px; -} - -.foreign-key ::ng-deep .mat-mdc-form-field-subscript-wrapper { - margin-bottom: 16px; -} - -.foreign-key ::ng-deep .mat-mdc-form-field-hint-wrapper { - padding-right: 0; - width: calc(100% + 8px); -} - -@media screen and (width <= 600px) { - .foreign-key ::ng-deep .mat-mdc-form-field-subscript-wrapper { - margin-bottom: 24px; - } - - .foreign-key ::ng-deep .mat-mdc-form-field-bottom-align::before { - margin-left: 0; - } -} - -.hint-link { - color: var(--color-accentedPalette-500); -} - -.foreign-key__link { - display: flex; - /* align-items: center; */ - justify-content: flex-end; - color: inherit; -} - -.foreign-key__link-icon { - margin-left: auto; - margin-top: 12px; -} - -.foreign-key__null-option { - font-style: italic; - color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6)); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html deleted file mode 100644 index 9678159c9..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.html +++ /dev/null @@ -1,38 +0,0 @@ -
- - {{normalizedLabel()}} - @if (fetching()) { - - } - - - @for (suggestion of suggestions(); track suggestion.fieldValue) { - - {{suggestion.displayString}} - - } - - Improve search performance by configuring Searchable foreign key columns  - here - - - @if (currentFieldValue != null) { - - - open_in_new - - - } -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts deleted file mode 100644 index 9b42774ba..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.spec.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { TablesService } from 'src/app/services/tables.service'; -import { ForeignKeyEditComponent } from './foreign-key.component'; - -describe('ForeignKeyEditComponent', () => { - let component: ForeignKeyEditComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - - const structureNetwork = [ - { - column_name: 'id', - column_default: "nextval('customers_id_seq'::regclass)", - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }, - { - column_name: 'firstname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'lastname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'email', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }, - { - column_name: 'age', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: null, - }, - ]; - - const usersTableNetwork = { - rows: [ - { - id: 33, - firstname: 'Alex', - lastname: 'Taylor', - email: 'new-user-5@email.com', - age: 24, - }, - { - id: 34, - firstname: 'Alex', - lastname: 'Johnson', - email: 'new-user-4@email.com', - age: 24, - }, - { - id: 35, - firstname: 'Alex', - lastname: 'Smith', - email: 'some-new@email.com', - age: 24, - }, - ], - primaryColumns: [ - { - column_name: 'id', - data_type: 'integer', - }, - ], - pagination: { - total: 30, - lastPage: 1, - perPage: 30, - currentPage: 1, - }, - sortable_by: [], - ordering: 'ASC', - structure: structureNetwork, - foreignKeys: [], - }; - - const fakeRelations = { - autocomplete_columns: ['firstname', 'lastname', 'email'], - column_name: 'userId', - constraint_name: '', - referenced_column_name: 'id', - referenced_table_name: 'users', - column_default: '', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - MatAutocompleteModule, - MatDialogModule, - Angulartics2Module.forRoot(), - ForeignKeyEditComponent, - BrowserAnimationsModule, - ], - providers: [provideHttpClient(), provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ForeignKeyEditComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('relations', fakeRelations); - tablesService = TestBed.inject(TablesService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should fill initial dropdown values when identity_column is set', async () => { - const usersTableNetworkWithIdentityColumn = { ...usersTableNetwork, identity_column: 'lastname' }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetworkWithIdentityColumn)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('value', ''); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toEqual('lastname'); - expect(component.currentDisplayedString).toBeUndefined; - expect(component.currentFieldValue).toBeUndefined; - - expect(component.suggestions()).toEqual([ - { - displayString: 'Taylor (Alex | new-user-5@email.com)', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Johnson (Alex | new-user-4@email.com)', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Smith (Alex | some-new@email.com)', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should fill initial dropdown values when identity_column is not set', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - - fixture.componentRef.setInput('value', ''); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toBeUndefined; - expect(component.currentDisplayedString).toBeUndefined; - expect(component.currentFieldValue).toBeUndefined; - - expect(component.suggestions()).toEqual([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should fill initial dropdown values when autocomplete_columns and field value is not set', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('relations', { - autocomplete_columns: [], - column_name: 'userId', - constraint_name: '', - referenced_column_name: 'id', - referenced_table_name: 'users', - column_default: '', - }); - fixture.componentRef.setInput('value', ''); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.identityColumn).toBeUndefined; - expect(component.currentDisplayedString).toBeUndefined; - expect(component.currentFieldValue).toBeUndefined; - - expect(component.suggestions()).toEqual([ - { - displayString: '33 | Alex | Taylor | new-user-5@email.com | 24', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: '34 | Alex | Johnson | new-user-4@email.com | 24', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: '35 | Alex | Smith | some-new@email.com | 24', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - }); - - it('should set current value if necessary row is in suggestions list', async () => { - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - component.currentDisplayedString = 'Alex | Johnson | new-user-4@email.com'; - - await component.fetchSuggestions(); - - expect(component.currentFieldValue).toEqual(34); - }); - - it('should fetch suggestions list if user types search query and identity column is set', async () => { - const searchSuggestionsNetwork = { - rows: [ - { - id: 23, - firstname: 'John', - lastname: 'Taylor', - email: 'new-user-0@email.com', - age: 24, - }, - { - id: 24, - firstname: 'John', - lastname: 'Johnson', - email: 'new-user-1@email.com', - age: 24, - }, - ], - primaryColumns: [{ column_name: 'id', data_type: 'integer' }], - identity_column: 'lastname', - }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - - fixture.componentRef.setInput('relations', fakeRelations); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'John'; - await component.fetchSuggestions(); - - expect(component.suggestions()).toEqual([ - { - displayString: 'Taylor (John | new-user-0@email.com)', - primaryKeys: { id: 23 }, - fieldValue: 23, - }, - { - displayString: 'Johnson (John | new-user-1@email.com)', - primaryKeys: { id: 24 }, - fieldValue: 24, - }, - ]); - }); - - it('should fetch suggestions list if user types search query and show No matches message if the list is empty', async () => { - const searchSuggestionsNetwork = { - rows: [], - }; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - primaryKeys: { id: 33 }, - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - primaryKeys: { id: 34 }, - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - primaryKeys: { id: 35 }, - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'skjfhskjdf'; - await component.fetchSuggestions(); - - expect(component.suggestions()).toEqual([ - { - displayString: 'No field starts with "skjfhskjdf" in foreign entity.', - }, - ]); - }); - - it('should fetch suggestions list if user types search query and identity column is not set', async () => { - const searchSuggestionsNetwork = { - rows: [ - { - id: 23, - firstname: 'John', - lastname: 'Taylor', - email: 'new-user-0@email.com', - age: 24, - }, - { - id: 24, - firstname: 'John', - lastname: 'Johnson', - email: 'new-user-1@email.com', - age: 24, - }, - ], - primaryColumns: [{ column_name: 'id', data_type: 'integer' }], - }; - - const fakeFetchTable = vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(searchSuggestionsNetwork)); - component.connectionID = '12345678'; - fixture.componentRef.setInput('relations', fakeRelations); - - component.suggestions.set([ - { - displayString: 'Alex | Taylor | new-user-5@email.com', - fieldValue: 33, - }, - { - displayString: 'Alex | Johnson | new-user-4@email.com', - fieldValue: 34, - }, - { - displayString: 'Alex | Smith | some-new@email.com', - fieldValue: 35, - }, - ]); - - component.currentDisplayedString = 'Alex'; - await component.fetchSuggestions(); - - fixture.detectChanges(); - - expect(fakeFetchTable).toHaveBeenCalledWith({ - connectionID: '12345678', - tableName: component.relations().referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: component.currentDisplayedString, - referencedColumn: component.relations().referenced_column_name, - }); - - expect(component.suggestions()).toEqual([ - { - displayString: 'John | Taylor | new-user-0@email.com', - primaryKeys: { id: 23 }, - fieldValue: 23, - }, - { - displayString: 'John | Johnson | new-user-1@email.com', - primaryKeys: { id: 24 }, - fieldValue: 24, - }, - ]); - }); - - describe('nullable column', () => { - const nullableStructure = { - column_name: 'userId', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: null, - }; - - it('appends a "— empty" null option when structure.allow_null is true', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('structure', nullableStructure); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.allowsNull()).toBe(true); - const suggestions = component.suggestions(); - expect(suggestions).toHaveLength(4); - expect(suggestions[suggestions.length - 1]).toEqual({ - displayString: '— empty', - fieldValue: null, - isNullOption: true, - }); - }); - - it('appends a null option when widget_params.allow_null is true', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('widgetStructure', { - field_name: 'userId', - widget_type: 'Foreign_key', - widget_params: { ...fakeRelations, allow_null: true }, - name: '', - description: '', - }); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.allowsNull()).toBe(true); - const suggestions = component.suggestions(); - expect(suggestions[suggestions.length - 1].isNullOption).toBe(true); - }); - - it('does NOT append a null option when the column is not nullable', async () => { - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of(usersTableNetwork)); - - component.connectionID = '12345678'; - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('structure', { ...nullableStructure, allow_null: false }); - - await component.ngOnInit(); - fixture.detectChanges(); - - expect(component.allowsNull()).toBe(false); - expect(component.suggestions().some((s) => s.isNullOption)).toBe(false); - }); - - it('keeps the null option present when search returns no rows', async () => { - fixture.componentRef.setInput('structure', nullableStructure); - component.connectionID = '12345678'; - - vi.spyOn(tablesService, 'fetchTable').mockReturnValue(of({ rows: [] })); - component.currentDisplayedString = 'nomatches'; - await component.fetchSuggestions(); - - expect(component.suggestions()).toEqual([ - { displayString: 'No field starts with "nomatches" in foreign entity.' }, - { displayString: '— empty', fieldValue: null, isNullOption: true }, - ]); - }); - - it('emits null and clears the related link when the null option is selected', () => { - const emitSpy = vi.spyOn(component.onFieldChange, 'emit'); - component.suggestions.set([ - { displayString: '— empty', fieldValue: null, isNullOption: true }, - { displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 }, - ]); - component.currentFieldQueryParams = { id: 33 }; - component.currentFieldValue = 33; - - component.updateRelatedLink({ option: { value: '— empty' } } as any); - - expect(component.currentFieldValue).toBeNull(); - expect(component.currentFieldQueryParams).toBeUndefined(); - expect(emitSpy).toHaveBeenCalledWith(null); - }); - - it('fetchSuggestions emits null when the current display string matches the null option', async () => { - const emitSpy = vi.spyOn(component.onFieldChange, 'emit'); - component.suggestions.set([ - { displayString: '— empty', fieldValue: null, isNullOption: true }, - { displayString: 'Alex | Taylor', primaryKeys: { id: 33 }, fieldValue: 33 }, - ]); - component.currentDisplayedString = '— empty'; - - await component.fetchSuggestions(); - - expect(component.currentFieldValue).toBeNull(); - expect(emitSpy).toHaveBeenCalledWith(null); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.stories.ts deleted file mode 100644 index 758e83a1b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ForeignKeyEditComponent } from './foreign-key.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/ForeignKey', - component: ForeignKeyEditComponent, - args: { - key: 'field_name', - label: 'Foreign Key Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '1', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts deleted file mode 100644 index 12528cfd9..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/foreign-key/foreign-key.component.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, model, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSpinner } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterModule } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; -import { TableForeignKey } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -interface FetchTableResponse { - rows: Record[]; - primaryColumns: { column_name: string; data_type: string }[]; - identity_column?: string; -} - -interface Suggestion { - displayString: string; - primaryKeys?: Record; - fieldValue?: unknown; - isNullOption?: boolean; -} - -const NULL_OPTION_LABEL = '— empty'; - -const nullSuggestion: Suggestion = { - displayString: NULL_OPTION_LABEL, - fieldValue: null, - isNullOption: true, -}; - -@Component({ - selector: 'app-edit-foreign-key', - templateUrl: './foreign-key.component.html', - styleUrls: ['./foreign-key.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatAutocompleteModule, - MatIconModule, - MatTooltipModule, - MatSpinner, - RouterModule, - ], -}) -export class ForeignKeyEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - private _tables = inject(TablesService); - private _connections = inject(ConnectionsService); - - public connectionID: string; - public currentDisplayedString: string; - public currentFieldValue: unknown; - public currentFieldQueryParams: Record; - public suggestions = signal([]); - public fetching = signal(true); - public identityColumn: string; - public primaeyKeys: { data_type: string; column_name: string }[]; - - public fkRelations: TableForeignKey = null; - - public allowsNull = computed(() => { - const ws = this.widgetStructure(); - return !!ws?.widget_params?.allow_null || !!this.structure()?.allow_null; - }); - - private _debounceTimer: ReturnType; - - async ngOnInit(): Promise { - super.ngOnInit(); - this.connectionID = this._connections.currentConnectionID; - - const ws = this.widgetStructure(); - if (ws?.widget_params) { - this.fkRelations = ws.widget_params as TableForeignKey; - } else if (this.relations()) { - this.fkRelations = this.relations(); - } - - if (this.fkRelations) { - try { - const res = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: this.fkRelations.referenced_table_name, - requstedPage: 1, - chunkSize: 10, - foreignKeyRowName: this.fkRelations.referenced_column_name, - foreignKeyRowValue: this.value(), - }), - )) as FetchTableResponse; - - if (res.rows.length) { - this.identityColumn = res.identity_column; - const modifiedRow = this.getModifiedRow(res.rows[0]); - if (this.value()) { - this.currentDisplayedString = this.identityColumn - ? `${res.rows[0][this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '); - this.currentFieldValue = res.rows[0][this.fkRelations.referenced_column_name]; - this.currentFieldQueryParams = Object.assign( - {}, - ...res.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: res.rows[0][primaeyKey.column_name], - })), - ); - } - } - - const suggestionsRes = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: this.fkRelations.referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: '', - referencedColumn: this.fkRelations.referenced_column_name, - }), - )) as FetchTableResponse; - - this.identityColumn = suggestionsRes.identity_column; - this.suggestions.set( - this.withNullOption( - suggestionsRes.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...suggestionsRes.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[this.fkRelations.referenced_column_name], - }; - }), - ), - ); - this.fetching.set(false); - } catch (error) { - console.error('Error loading foreign key data:', error); - this.fetching.set(false); - } - } - } - - onSearchInput(): void { - clearTimeout(this._debounceTimer); - this._debounceTimer = setTimeout(() => { - if (this.currentDisplayedString === '') this.onFieldChange.emit(null); - this.fetchSuggestions(); - }, 500); - } - - async fetchSuggestions(): Promise { - const currentRow = this.suggestions()?.find( - (suggestion) => suggestion.displayString === this.currentDisplayedString, - ); - if (currentRow !== undefined) { - this.currentFieldValue = currentRow.fieldValue; - this.onFieldChange.emit(this.currentFieldValue); - } else { - this.fetching.set(true); - try { - const res = (await firstValueFrom( - this._tables.fetchTable({ - connectionID: this.connectionID, - tableName: this.fkRelations.referenced_table_name, - requstedPage: 1, - chunkSize: 20, - foreignKeyRowName: 'autocomplete', - foreignKeyRowValue: this.currentDisplayedString, - referencedColumn: this.fkRelations.referenced_column_name, - }), - )) as FetchTableResponse; - - this.identityColumn = res.identity_column; - if (res.rows.length === 0) { - this.suggestions.set( - this.withNullOption([ - { - displayString: `No field starts with "${this.currentDisplayedString}" in foreign entity.`, - }, - ]), - ); - } else { - this.suggestions.set( - this.withNullOption( - res.rows.map((row) => { - const modifiedRow = this.getModifiedRow(row); - return { - displayString: this.identityColumn - ? `${row[this.identityColumn]} (${Object.values(modifiedRow) - .filter((value) => value) - .join(' | ')})` - : Object.values(modifiedRow) - .filter((value) => value) - .join(' | '), - primaryKeys: Object.assign( - {}, - ...res.primaryColumns.map((primaeyKey) => ({ - [primaeyKey.column_name]: row[primaeyKey.column_name], - })), - ), - fieldValue: row[this.fkRelations.referenced_column_name], - }; - }), - ), - ); - } - this.fetching.set(false); - } catch (error) { - console.error('Error fetching suggestions:', error); - this.fetching.set(false); - } - } - } - - getModifiedRow(row: Record): Record { - let modifiedRow: Record; - if (this.fkRelations.autocomplete_columns && this.fkRelations.autocomplete_columns.length > 0) { - let autocompleteColumns = [...this.fkRelations.autocomplete_columns]; - if (this.identityColumn) - autocompleteColumns.splice(this.fkRelations.autocomplete_columns.indexOf(this.identityColumn), 1); - modifiedRow = autocompleteColumns.reduce>( - (rowObject, columnName) => ((rowObject[columnName] = row[columnName]), rowObject), - {}, - ); - } else { - modifiedRow = Object.entries(row).reduce>((rowObject, [columnName, value]) => { - if (value) { - rowObject[columnName] = value; - } - return rowObject; - }, {}); - } - return modifiedRow; - } - - updateRelatedLink(e: MatAutocompleteSelectedEvent) { - const selected = this.suggestions().find((suggestion) => suggestion.displayString === e.option.value); - if (selected?.isNullOption) { - this.currentFieldValue = null; - this.currentFieldQueryParams = undefined; - this.onFieldChange.emit(null); - return; - } - this.currentFieldQueryParams = selected?.primaryKeys; - } - - private withNullOption(suggestions: Suggestion[]): Suggestion[] { - return this.allowsNull() ? [...suggestions, nullSuggestion] : suggestions; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.css b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.css deleted file mode 100644 index bffe35774..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.id-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html deleted file mode 100644 index 8b0a93f85..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.html +++ /dev/null @@ -1,12 +0,0 @@ - - {{normalizedLabel()}} - - @if (idField.errors?.pattern) { - Value doesn't match pattern. - } - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.spec.ts deleted file mode 100644 index cbc9a4a9b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { IdEditComponent } from './id.component'; - -describe('IdEditComponent', () => { - let component: IdEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, IdEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(IdEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.stories.ts deleted file mode 100644 index 7f5b5df93..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { IdEditComponent } from './id.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Id', - component: IdEditComponent, - args: { - key: 'field_name', - label: 'ID Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '12345', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts deleted file mode 100644 index 27bfe59f6..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/id/id.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-id', - templateUrl: './id.component.html', - styleUrls: ['./id.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class IdEditComponent extends BaseEditFieldComponent { - readonly value = model(); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.css b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.css deleted file mode 100644 index 2a3feec64..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.image-box__url { - width: 100%; -} - -.image-box__image { - /* width: 100%; */ - height: auto; - max-height: 200px; - margin-top: 4px; - margin-bottom: 20px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html deleted file mode 100644 index 6912ae595..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
- - {{normalizedLabel()}} - @if (prefix) { - {{prefix}} - } - - @if (image.errors?.isInvalidURL) { - URL is invalid. - } - - @if (!image.errors?.isInvalidURL) { - - } -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts deleted file mode 100644 index ca2d5a45e..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { ImageEditComponent } from './image.component'; - -describe('ImageComponent', () => { - let component: ImageEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, ImageEditComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(ImageEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have empty prefix by default', () => { - expect(component.prefix).toBe(''); - }); - - it('should parse prefix from widget params object', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.example.com/' } } as any); - component.ngOnInit(); - expect(component.prefix).toBe('https://cdn.example.com/'); - }); - - it('should parse prefix from widget params string', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ prefix: 'https://images.test/' }) } as any); - component.ngOnInit(); - expect(component.prefix).toBe('https://images.test/'); - }); - - it('should keep empty prefix when widget params have no prefix', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); - component.ngOnInit(); - expect(component.prefix).toBe(''); - }); - - it('should update prefix on ngOnChanges', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.test/' } } as any); - component.ngOnChanges(); - expect(component.prefix).toBe('https://cdn.test/'); - }); - - it('should return empty string for imageUrl when value is empty', () => { - fixture.componentRef.setInput('value', ''); - expect(component.imageUrl).toBe(''); - }); - - it('should return value without prefix when prefix is empty', () => { - fixture.componentRef.setInput('value', 'photo.jpg'); - expect(component.imageUrl).toBe('photo.jpg'); - }); - - it('should return prefix + value for imageUrl', () => { - component.prefix = 'https://cdn.example.com/'; - fixture.componentRef.setInput('value', 'photo.jpg'); - expect(component.imageUrl).toBe('https://cdn.example.com/photo.jpg'); - }); - - it('should handle invalid JSON in widget params gracefully', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: 'invalid-json' } as any); - component.ngOnInit(); - expect(component.prefix).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.stories.ts deleted file mode 100644 index 99e02ead5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ImageEditComponent } from './image.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Image', - component: ImageEditComponent, - args: { - key: 'field_name', - label: 'Image Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'https://via.placeholder.com/150', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts deleted file mode 100644 index 6cb24b5e1..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/image/image.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { UrlValidatorDirective } from 'src/app/directives/url-validator.directive'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-image', - templateUrl: './image.component.html', - styleUrl: './image.component.css', - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, UrlValidatorDirective], -}) -export class ImageEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - public prefix: string = ''; - - ngOnInit(): void { - super.ngOnInit(); - this._parseWidgetParams(); - } - - ngOnChanges(): void { - this._parseWidgetParams(); - } - - get imageUrl(): string { - const val = this.value(); - if (!val) return ''; - return this.prefix + val; - } - - private _parseWidgetParams(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - try { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - - if (params.prefix !== undefined) { - this.prefix = params.prefix || ''; - } - } catch (e) { - console.error('Error parsing Image widget params:', e); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.css b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.css deleted file mode 100644 index 05e90e456..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.css +++ /dev/null @@ -1,16 +0,0 @@ -.code-editor-box { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 4px; - margin-top: 4px; - margin-bottom: 20px; - /* overflow-y: auto; - resize: vertical; */ - overflow: auto; - resize: both; -} - -.code-editor-box ::ng-deep .ngs-code-editor { - width: 100%; - height: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html deleted file mode 100644 index 6dce1ee7a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ normalizedLabel() }} {{ required() ? '*' : '' }} - -
- - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts deleted file mode 100644 index 0cd651222..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { JsonEditorEditComponent } from './json-editor.component'; - -describe('JsonEditorEditComponent', () => { - let component: JsonEditorEditComponent; - let fixture: ComponentFixture; - - const mockUiSettingsService = { - isDarkMode: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [JsonEditorEditComponent, BrowserAnimationsModule], - providers: [{ provide: UiSettingsService, useValue: mockUiSettingsService }], - }) - .overrideComponent(JsonEditorEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(JsonEditorEditComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('label', 'metadata'); - fixture.componentRef.setInput('value', { id: 1, name: 'test', settings: { enabled: true } }); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize mutableCodeModel with JSON language', () => { - expect(component.mutableCodeModel).toBeDefined(); - expect((component.mutableCodeModel as any).language).toBe('json'); - }); - - it('should set URI based on label', () => { - expect((component.mutableCodeModel as any).uri).toBe('metadata.json'); - }); - - it('should stringify value with 4-space indentation', () => { - const modelValue = (component.mutableCodeModel as any).value; - expect(modelValue).toContain('"id": 1'); - expect(modelValue).toContain('"name": "test"'); - expect(modelValue).toContain('"settings"'); - }); - - it('should have correct code editor options', () => { - expect(component.codeEditorOptions.minimap.enabled).toBe(false); - expect(component.codeEditorOptions.automaticLayout).toBe(true); - expect(component.codeEditorOptions.scrollBeyondLastLine).toBe(false); - expect(component.codeEditorOptions.wordWrap).toBe('on'); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - // JSON.stringify(null) returns "null", fallback to '{}' only for undefined - expect((component.mutableCodeModel as any).value).toBe('null'); - }); - - it('should handle undefined value with fallback', () => { - fixture.componentRef.setInput('value', undefined); - component.ngOnInit(); - // undefined is falsy so falls back to '{}' - expect((component.mutableCodeModel as any).value).toBe('{}'); - }); - - it('should handle array value', () => { - fixture.componentRef.setInput('value', [1, 2, 3] as any); - component.ngOnInit(); - const modelValue = (component.mutableCodeModel as any).value; - expect(modelValue).toContain('1'); - expect(modelValue).toContain('2'); - expect(modelValue).toContain('3'); - }); - - it('should normalize label from base class', () => { - fixture.componentRef.setInput('label', 'json_config_data'); - component.ngOnInit(); - expect(component.normalizedLabel()).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.stories.ts deleted file mode 100644 index b3e42ac92..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { JsonEditorEditComponent } from './json-editor.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'json', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/JsonEditor', - component: JsonEditorEditComponent, - args: { - key: 'field_name', - label: 'JSON Editor Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: { key: 'value', nested: { a: 1 } }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts deleted file mode 100644 index 093a6c5f1..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/json-editor/json-editor.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-json-editor', - templateUrl: './json-editor.component.html', - styleUrls: ['./json-editor.component.css'], - imports: [CommonModule, CodeEditorModule], -}) -export class JsonEditorEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - private _uiSettings = inject(UiSettingsService); - - public mutableCodeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme = 'vs-dark'; - - ngOnInit(): void { - super.ngOnInit(); - this.mutableCodeModel = { - language: 'json', - uri: `${this.label()}.json`, - value: JSON.stringify(this.value(), undefined, 4) || '{}', - }; - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.css b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.css deleted file mode 100644 index 60a6679e7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.css +++ /dev/null @@ -1,49 +0,0 @@ -.language-form-field { - width: 100%; -} - -.language-input-container { - display: flex; - align-items: center; - width: 100%; -} - -.language-flag-prefix { - font-size: 20px; - margin-right: 8px; - line-height: 1; -} - -.language-input { - flex: 1; - border: none !important; - outline: none !important; - background: transparent !important; -} - -.language-flag { - margin-right: 8px; - font-size: 16px; -} - -.language-name { - flex: 1; -} - -.language-native { - margin-left: 8px; - opacity: 0.8; - font-size: 13px; - font-style: italic; -} - -.language-code { - margin-left: 8px; - opacity: 0.7; - font-size: 12px; -} - -mat-option { - display: flex; - align-items: center; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html deleted file mode 100644 index 4986d60a2..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - {{normalizedLabel()}} -
- @if (selectedLanguageFlag() && showFlag()) { - {{selectedLanguageFlag()}} - } - -
- - @for (language of filteredLanguages(); track language.value) { - - @if (language.flag && showFlag()) { - {{language.flag}} - } - {{language.label}} - @if (language.nativeName && language.label !== language.nativeName) { - {{language.nativeName}} - } - @if (language.value) { - ({{language.value}}) - } - - } - -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts deleted file mode 100644 index caa590924..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { LanguageEditComponent } from './language.component'; - -describe('LanguageEditComponent', () => { - let component: LanguageEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LanguageEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LanguageEditComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('widgetStructure', { widget_params: {} }); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load languages on init', () => { - expect(component.languages.length).toBeGreaterThan(0); - }); - - it('should set initial value when value is provided', () => { - fixture.componentRef.setInput('value', 'en'); - component.ngOnInit(); - expect(component.selectedLanguageFlag()).toBeTruthy(); - }); - - it('should parse widget params for show_flag', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { show_flag: false }, - }); - fixture.detectChanges(); - expect(component.showFlag()).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.stories.ts deleted file mode 100644 index 08bc3a2a7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LanguageEditComponent } from './language.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Language', - component: LanguageEditComponent, - args: { - key: 'field_name', - label: 'Language Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'en', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts deleted file mode 100644 index da1cd31ab..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/language/language.component.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, computed, input, OnInit, output } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; -import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; -import { normalizeFieldName } from '../../../../lib/normalize'; - -interface LanguageOption { - value: string | null; - label: string; - flag: string; - nativeName?: string; -} - -@Component({ - selector: 'app-edit-language', - imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatAutocompleteModule, MatInputModule], - templateUrl: './language.component.html', - styleUrls: ['./language.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class LanguageEditComponent implements OnInit { - readonly key = input(); - readonly label = input(); - readonly required = input(false); - readonly readonly = input(false); - readonly structure = input(); - readonly disabled = input(false); - readonly widgetStructure = input(); - readonly relations = input(); - readonly value = input(); - - readonly onFieldChange = output(); - - readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - - readonly showFlag = computed(() => { - const ws = this.widgetStructure(); - if (!ws?.widget_params) return true; - try { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - return params.show_flag !== undefined ? params.show_flag : true; - } catch { - return true; - } - }); - - public languages: LanguageOption[] = []; - public languageControl = new FormControl(''); - public selectedLanguageFlag = computed(() => { - const value = this._controlValue(); - if (typeof value === 'object' && value !== null) { - return value.flag; - } - return ''; - }); - - private _controlValue = toSignal(this.languageControl.valueChanges, { initialValue: '' as LanguageOption | string }); - - public filteredLanguages = computed(() => { - const value = this._controlValue(); - return this._filter(typeof value === 'string' ? value : value?.label || ''); - }); - - originalOrder = () => { - return 0; - }; - - ngOnInit(): void { - this.loadLanguages(); - this.setInitialValue(); - } - - displayFn(language: LanguageOption | string): string { - if (!language) return ''; - return typeof language === 'string' ? language : language.label; - } - - onLanguageSelected(selectedLanguage: LanguageOption): void { - this.onFieldChange.emit(selectedLanguage.value); - } - - private setInitialValue(): void { - const val = this.value(); - if (val) { - const language = this.languages.find((l) => l.value && l.value.toLowerCase() === val.toLowerCase()); - if (language) { - this.languageControl.setValue(language); - } - } - } - - private _filter(value: string): LanguageOption[] { - const filterValue = value.toLowerCase(); - return this.languages.filter( - (language) => - language.label?.toLowerCase().includes(filterValue) || - language.value?.toLowerCase().includes(filterValue) || - language.nativeName?.toLowerCase().includes(filterValue), - ); - } - - private loadLanguages(): void { - this.languages = LANGUAGES.map((language) => ({ - value: language.code, - label: language.name, - flag: getLanguageFlag(language), - nativeName: language.nativeName, - })).toSorted((a, b) => a.label.localeCompare(b.label)); - - const ws = this.widgetStructure(); - if (ws?.widget_params?.allow_null || this.structure()?.allow_null) { - this.languages = [{ value: null, label: '', flag: '' }, ...this.languages]; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.css b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.css deleted file mode 100644 index e390d1047..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.css +++ /dev/null @@ -1,16 +0,0 @@ -.form-field { - position: relative; - width: 100%; -} - -.long-textarea { - margin-bottom: 20px; -} - -.counter { - position: absolute; - bottom: 8px; - font-size: 12px; - width: 90%; - background: #fff; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html deleted file mode 100644 index 7ac207d5e..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.html +++ /dev/null @@ -1,27 +0,0 @@ - - {{normalizedLabel()}} - - @if (maxLength && maxLength > 0 && value() && (maxLength - value().length) < 100) { -
{{value().length}} / {{maxLength}}
- } - @if (textareaField.errors?.['required']) { - This field is required. - } - @if (textareaField.errors?.['maxlength']) { - Maximum length is {{maxLength}} characters. - } - @if (textareaField.errors?.['invalidPattern'] || textareaField.errors?.[('invalid' + validateType)]) { - {{getValidationErrorMessage()}} - } -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts deleted file mode 100644 index c503cc958..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { LongTextEditComponent } from './long-text.component'; - -describe('LongTextEditComponent', () => { - let component: LongTextEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LongTextEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LongTextEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set maxLength from structure character_maximum_length', () => { - fixture.componentRef.setInput('structure', { character_maximum_length: 1000 } as any); - component.ngOnInit(); - expect(component.maxLength).toBe(1000); - }); - - it('should keep maxLength null when structure has no character_maximum_length', () => { - fixture.componentRef.setInput('structure', {} as any); - component.ngOnInit(); - expect(component.maxLength).toBeNull(); - }); - - it('should default rowsCount to 4 when no widget params', () => { - component.ngOnInit(); - expect(component.rowsCount).toBe('4'); - }); - - it('should parse rowsCount from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { rows: '10' } } as any); - component.ngOnInit(); - expect(component.rowsCount).toBe('10'); - }); - - it('should parse validateType from widget params object', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isJSON' } } as any); - component.ngOnInit(); - expect(component.validateType).toBe('isJSON'); - }); - - it('should parse validateType from widget params string', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ validate: 'isEmail', rows: '6' }) } as any); - component.ngOnInit(); - expect(component.validateType).toBe('isEmail'); - expect(component.rowsCount).toBe('6'); - }); - - it('should parse regexPattern from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'regex', regex: '^\\d+$' } } as any); - component.ngOnInit(); - expect(component.regexPattern).toBe('^\\d+$'); - }); - - it('should return empty string for getValidationErrorMessage when no validateType', () => { - component.validateType = null; - expect(component.getValidationErrorMessage()).toBe(''); - }); - - it('should return regex message for regex validateType', () => { - component.validateType = 'regex'; - expect(component.getValidationErrorMessage()).toBe("Value doesn't match the required pattern"); - }); - - it('should return correct message for known validateType', () => { - component.validateType = 'isIP'; - expect(component.getValidationErrorMessage()).toBe('Invalid IP address'); - }); - - it('should return fallback message for unknown validateType', () => { - component.validateType = 'customRule'; - expect(component.getValidationErrorMessage()).toBe('Invalid customRule'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.stories.ts deleted file mode 100644 index 4c924979a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.stories.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LongTextEditComponent } from './long-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/LongText', - component: LongTextEditComponent, - args: { - key: 'field_name', - label: 'Long Text Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts deleted file mode 100644 index 2be2040e8..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/long-text/long-text.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { TextValidatorDirective } from 'src/app/directives/text-validator.directive'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-long-text', - templateUrl: './long-text.component.html', - styleUrls: ['./long-text.component.css'], - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective], -}) -export class LongTextEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - - static type = 'text'; - public rowsCount: string; - maxLength: number | null = null; - validateType: string | null = null; - regexPattern: string | null = null; - - override ngOnInit(): void { - super.ngOnInit(); - - const struct = this.structure(); - if (struct?.character_maximum_length) { - this.maxLength = struct.character_maximum_length; - } - - const ws = this.widgetStructure(); - if (ws?.widget_params) { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - - this.rowsCount = params.rows || '4'; - this.validateType = params.validate || null; - this.regexPattern = params.regex || null; - } else { - this.rowsCount = '4'; - } - } - - getValidationErrorMessage(): string { - if (!this.validateType) { - return ''; - } - - if (this.validateType === 'regex') { - return "Value doesn't match the required pattern"; - } - - const messages = { - isEmail: 'Invalid email address', - isURL: 'Invalid URL', - isIP: 'Invalid IP address', - isUUID: 'Invalid UUID', - isJSON: 'Invalid JSON', - isCreditCard: 'Invalid credit card number', - isISBN: 'Invalid ISBN', - isAlpha: 'Should contain only letters', - isNumeric: 'Should contain only numbers', - isAlphanumeric: 'Should contain only letters and numbers', - isHexColor: 'Invalid hex color', - isBase64: 'Invalid Base64 string', - isMobilePhone: 'Invalid mobile phone number', - isMACAddress: 'Invalid MAC address', - isPostalCode: 'Invalid postal code', - isCurrency: 'Invalid currency format', - }; - - return messages[this.validateType] || `Invalid ${this.validateType}`; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css deleted file mode 100644 index 90c359617..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.code-editor-box { - min-height: 200px; - border-radius: 4px; - overflow: hidden; -} - -@media (prefers-color-scheme: light) { - .code-editor-box { - border: 1px solid #ccc; - } -} - -@media (prefers-color-scheme: dark) { - .code-editor-box { - border: 1px solid #404040; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html deleted file mode 100644 index 6dce1ee7a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.html +++ /dev/null @@ -1,11 +0,0 @@ -{{ normalizedLabel() }} {{ required() ? '*' : '' }} - -
- - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts deleted file mode 100644 index 33a653f03..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { MarkdownEditComponent } from './markdown.component'; - -describe('MarkdownEditComponent', () => { - let component: MarkdownEditComponent; - let fixture: ComponentFixture; - - const mockUiSettingsService = { - isDarkMode: true, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MarkdownEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), { provide: UiSettingsService, useValue: mockUiSettingsService }], - }) - .overrideComponent(MarkdownEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(MarkdownEditComponent); - component = fixture.componentInstance; - - fixture.componentRef.setInput('widgetStructure', { - widget_params: {}, - } as any); - fixture.componentRef.setInput('label', 'description'); - fixture.componentRef.setInput('value', '# Hello World\n\nThis is **bold** text.'); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize mutableCodeModel with markdown language', () => { - expect(component.mutableCodeModel).toBeDefined(); - expect((component.mutableCodeModel as any).language).toBe('markdown'); - }); - - it('should set URI with .md extension based on label', () => { - expect((component.mutableCodeModel as any).uri).toBe('description.md'); - }); - - it('should set value from input', () => { - expect((component.mutableCodeModel as any).value).toBe('# Hello World\n\nThis is **bold** text.'); - }); - - it('should use editor theme from UiSettingsService', () => { - expect(component.codeEditorTheme).toBe('vs-dark'); - }); - - it('should have correct code editor options', () => { - expect(component.codeEditorOptions.minimap.enabled).toBe(false); - expect(component.codeEditorOptions.automaticLayout).toBe(true); - expect(component.codeEditorOptions.scrollBeyondLastLine).toBe(false); - expect(component.codeEditorOptions.wordWrap).toBe('on'); - }); - - it('should use light theme when configured', () => { - const lightThemeService = { isDarkMode: false }; - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [MarkdownEditComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), { provide: UiSettingsService, useValue: lightThemeService }], - }) - .overrideComponent(MarkdownEditComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - const newFixture = TestBed.createComponent(MarkdownEditComponent); - const newComponent = newFixture.componentInstance; - newFixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); - newFixture.componentRef.setInput('label', 'content'); - newFixture.componentRef.setInput('value', 'test'); - newFixture.detectChanges(); - - expect(newComponent.codeEditorTheme).toBe('vs'); - }); - - it('should normalize label from base class', () => { - fixture.componentRef.setInput('label', 'product_description'); - component.ngOnInit(); - expect(component.normalizedLabel()).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.stories.ts deleted file mode 100644 index 492241aa5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MarkdownEditComponent } from './markdown.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Markdown', - component: MarkdownEditComponent, - args: { - key: 'field_name', - label: 'Markdown Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '# Hello\nThis is **markdown** content with a [link](https://example.com).', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts deleted file mode 100644 index a64e0c41b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/markdown/markdown.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject, model } from '@angular/core'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-markdown', - templateUrl: './markdown.component.html', - styleUrl: './markdown.component.css', - imports: [CommonModule, CodeEditorModule], -}) -export class MarkdownEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - private _uiSettings = inject(UiSettingsService); - - public mutableCodeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme = 'vs-dark'; - - ngOnInit(): void { - super.ngOnInit(); - this.mutableCodeModel = { - language: 'markdown', - uri: `${this.label()}.md`, - value: this.value(), - }; - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.css b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.css deleted file mode 100644 index cafa4de54..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.css +++ /dev/null @@ -1,74 +0,0 @@ -.money-widget { - width: 100%; - margin-left: 0; - margin-right: 0; -} - -.money-input-container { - display: flex; - gap: 12px; - align-items: flex-start; -} - -.money-selector { - min-width: 180px; - flex-shrink: 0; - margin-left: 16px; -} - -.money-selector .mat-mdc-form-field { - margin-left: 0; -} - -.amount-input { - flex: 1; - min-width: 120px; -} - -/* Responsive design */ -@media (max-width: 768px) { - .money-input-container { - flex-direction: column; - gap: 8px; - } - - .money-selector { - width: 100%; - min-width: unset; - margin-left: 16px; - } -} - -/* Ensure proper spacing with Material Design */ -.mat-mdc-form-field { - margin-bottom: 0; - margin-left: 0; - margin-right: 0; -} - -.mat-mdc-form-field + .mat-mdc-form-field { - margin-left: 0; -} - -/* Prefix/suffix styling */ -.mat-mdc-form-field-prefix, -.mat-mdc-form-field-suffix { - font-weight: 500; - color: rgba(0, 0, 0, 0.87); -} - -[data-theme="dark"] .mat-mdc-form-field-prefix, -[data-theme="dark"] .mat-mdc-form-field-suffix { - color: rgba(255, 255, 255, 0.87); -} - -/* Specific styling for text prefix */ -.mat-mdc-form-field-text-prefix { - font-weight: 500; - color: rgba(0, 0, 0, 0.87); - margin-right: 4px; -} - -[data-theme="dark"] .mat-mdc-form-field-text-prefix { - color: rgba(255, 255, 255, 0.87); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html deleted file mode 100644 index accbb98e5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.html +++ /dev/null @@ -1,46 +0,0 @@ -
-
- - @if (showCurrencySelector) { - - Currency - - @for (currency of currencies; track currency.code) { - - {{ displayCurrencyFn(currency) }} - - } - - - } - - - - {{normalizedLabel()}} - @if (selectedCurrencyData && displayAmount) { - {{selectedCurrencyData.symbol}} - } - - -
- -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts deleted file mode 100644 index 854e0f5d4..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.spec.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { MoneyEditComponent } from './money.component'; - -describe('MoneyEditComponent', () => { - let component: MoneyEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MoneyEditComponent, - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - BrowserAnimationsModule, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(MoneyEditComponent); - component = fixture.componentInstance; - - // Set required properties from base component - fixture.componentRef.setInput('label', 'Test Money'); - fixture.componentRef.setInput('required', false); - fixture.componentRef.setInput('disabled', false); - fixture.componentRef.setInput('readonly', false); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with default currency USD and currency selector disabled', () => { - expect(component.selectedCurrency).toBe('USD'); - expect(component.defaultCurrency).toBe('USD'); - expect(component.showCurrencySelector).toBe(false); - }); - - it('should parse string value correctly', () => { - fixture.componentRef.setInput('value', '100.50 EUR'); - component.ngOnInit(); - expect(component.selectedCurrency).toBe('EUR'); - expect(component.amount).toBe(100.5); - }); - - it('should parse object value correctly', () => { - fixture.componentRef.setInput('value', { amount: 250.75, currency: 'GBP' }); - component.ngOnInit(); - expect(component.selectedCurrency).toBe('GBP'); - expect(component.amount).toBe(250.75); - }); - - it('should parse numeric value correctly when currency selector is disabled', () => { - fixture.componentRef.setInput('value', 150.25); - component.ngOnInit(); - expect(component.selectedCurrency).toBe('USD'); - expect(component.amount).toBe(150.25); - expect(component.displayAmount).toBe('150.25'); - }); - - it('should handle empty value', () => { - fixture.componentRef.setInput('value', ''); - component.ngOnInit(); - expect(component.selectedCurrency).toBe('USD'); - expect(component.amount).toBe(''); - }); - - it('should format amount with correct decimal places', () => { - component.decimalPlaces = 2; - const formatted = component.formatAmount(123.456); - expect(formatted).toBe('123.46'); - }); - - it('should handle currency change when selector is enabled', () => { - component.showCurrencySelector = true; - component.selectedCurrency = 'EUR'; - component.amount = 100; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onCurrencyChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ - amount: 100, - currency: 'EUR', - }); - }); - - it('should handle amount change with currency selector disabled (default)', () => { - component.displayAmount = '123.45'; - component.selectedCurrency = 'USD'; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onAmountChange(); - - expect(component.amount).toBe(123.45); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(123.45); - }); - - it('should handle amount change with currency selector enabled', () => { - component.showCurrencySelector = true; - component.displayAmount = '123.45'; - component.selectedCurrency = 'USD'; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onAmountChange(); - - expect(component.amount).toBe(123.45); - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ - amount: 123.45, - currency: 'USD', - }); - }); - - it('should handle invalid amount input with letters', () => { - component.amount = 100; - component.displayAmount = 'abc123def'; // Contains letters which get stripped - - component.onAmountChange(); - component.onAmountBlur(); - - expect(component.amount).toBe(123); - expect(component.displayAmount).toBe('123.00'); - }); - - it('should handle completely invalid input', () => { - component.amount = 100 as string | number; - component.displayAmount = 'invalid'; // All letters, becomes empty after strip - - component.onAmountChange(); - - expect(component.amount as string).toBe(''); - expect(component.displayAmount).toBe(''); - }); - - it('should handle invalid amount input when amount is empty', () => { - component.amount = ''; - component.displayAmount = 'invalid'; - - component.onAmountChange(); - - expect(component.displayAmount).toBe(''); - }); - - it('should respect allow_negative configuration', () => { - component.allowNegative = false; - component.displayAmount = '-123.45'; - - component.onAmountChange(); - - expect(component.amount).toBe(123.45); - }); - - it('should configure from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'test_field', - widget_type: 'Money', - name: 'Test Widget', - description: 'Test Description', - widget_params: { - default_currency: 'EUR', - show_currency_selector: true, - decimal_places: 3, - allow_negative: false, - }, - }); - - component.configureFromWidgetParams(); - - expect(component.defaultCurrency).toBe('EUR'); - expect(component.showCurrencySelector).toBe(true); - expect(component.decimalPlaces).toBe(3); - expect(component.allowNegative).toBe(false); - }); - - it('should return correct display value', () => { - component.amount = 123.45; - component.selectedCurrency = 'USD'; - - const displayValue = component.displayValue; - - expect(displayValue).toBe('$123.45'); - }); - - it('should return correct placeholder', () => { - component.selectedCurrency = 'EUR'; - - const placeholder = component.placeholder; - - expect(placeholder).toBe('Enter amount in Euro'); - }); - - it('should find selected currency data', () => { - component.selectedCurrency = 'GBP'; - - const currencyData = component.selectedCurrencyData; - - expect(currencyData.code).toBe('GBP'); - expect(currencyData.name).toBe('British Pound'); - expect(currencyData.symbol).toBe('£'); - }); - - it('should emit empty value when amount is cleared', () => { - component.amount = ''; - vi.spyOn(component.onFieldChange, 'emit'); - - component.updateValue(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(''); - }); - - it('should load numeric value as major units when cents=true (USD)', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'USD', cents: true }, - }); - fixture.componentRef.setInput('value', 1099); - component.ngOnInit(); - expect(component.amount).toBe(10.99); - expect(component.displayAmount).toBe('10.99'); - expect(component.decimalPlaces).toBe(2); - }); - - it('should load JPY cents value without division and use 0 decimals', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'JPY', cents: true, show_currency_selector: true }, - }); - fixture.componentRef.setInput('value', { amount: 1099, currency: 'JPY' }); - component.ngOnInit(); - expect(component.selectedCurrency).toBe('JPY'); - expect(component.decimalPlaces).toBe(0); - expect(component.amount).toBe(1099); - expect(component.displayAmount).toBe('1099'); - }); - - it('should emit cents integer on save when cents=true', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'USD', cents: true }, - }); - component.ngOnInit(); - component.displayAmount = '10.99'; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onAmountChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(1099); - }); - - it('should round float-precision drift on save', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'USD', cents: true }, - }); - component.ngOnInit(); - component.displayAmount = '20.99'; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onAmountChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(2099); - }); - - it('should reformat displayAmount and round on currency switch when cents=true', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'USD', cents: true, show_currency_selector: true }, - }); - fixture.componentRef.setInput('value', { amount: 1099, currency: 'USD' }); - component.ngOnInit(); - expect(component.displayAmount).toBe('10.99'); - - component.selectedCurrency = 'JPY'; - vi.spyOn(component.onFieldChange, 'emit'); - - component.onCurrencyChange(); - - expect(component.decimalPlaces).toBe(0); - expect(component.displayAmount).toBe('11'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith({ amount: 11, currency: 'JPY' }); - }); - - it('should preserve legacy behavior when cents is false or omitted', () => { - fixture.componentRef.setInput('widgetStructure', { - field_name: 'price', - widget_type: 'Money', - name: 'Price', - description: '', - widget_params: { default_currency: 'USD' }, - }); - fixture.componentRef.setInput('value', 10.99); - component.ngOnInit(); - expect(component.amount).toBe(10.99); - expect(component.displayAmount).toBe('10.99'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.stories.ts deleted file mode 100644 index 161f49b2a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MoneyEditComponent } from './money.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'money', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Money', - component: MoneyEditComponent, - args: { - key: 'field_name', - label: 'Money Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '99.99', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts deleted file mode 100644 index 801d0bccf..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/money/money.component.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { - CURRENCIES, - getCurrencyDecimalPlaces, - getCurrencyMinorUnitFactor, - Money, - MoneyValue, -} from 'src/app/consts/currencies'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-money', - templateUrl: './money.component.html', - styleUrls: ['./money.component.css'], - imports: [CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, FormsModule], -}) -export class MoneyEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(''); - - static type = 'money'; - - defaultCurrency: string = 'USD'; - showCurrencySelector: boolean = false; - decimalPlaces: number = 2; - allowNegative: boolean = true; - cents: boolean = false; - - selectedCurrency: string = 'USD'; - amount: number | string = ''; - displayAmount: string = ''; - - currencies: Money[] = CURRENCIES; - - ngOnInit(): void { - super.ngOnInit(); - this.configureFromWidgetParams(); - this.initializeMoneyValue(); - } - - configureFromWidgetParams(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - const params = ws.widget_params; - - if (typeof params.default_currency === 'string') { - this.defaultCurrency = params.default_currency; - } - - if (typeof params.show_currency_selector === 'boolean') { - this.showCurrencySelector = params.show_currency_selector; - } - - if (typeof params.decimal_places === 'number') { - this.decimalPlaces = Math.max(0, Math.min(10, params.decimal_places)); - } - - if (typeof params.allow_negative === 'boolean') { - this.allowNegative = params.allow_negative; - } - - if (params.cents === true) { - this.cents = true; - } - } - - this._applyCurrencyDecimalPlaces(); - } - - private initializeMoneyValue(): void { - const currentValue = this.value(); - if (currentValue !== '' && currentValue !== null && currentValue !== undefined) { - if (typeof currentValue === 'string') { - this.parseStringValue(currentValue); - } else if ( - typeof currentValue === 'object' && - (currentValue as MoneyValue).amount !== undefined && - (currentValue as MoneyValue).currency - ) { - this.selectedCurrency = (currentValue as MoneyValue).currency; - this._applyCurrencyDecimalPlaces(); - this.amount = this._fromMinorUnits((currentValue as MoneyValue).amount); - this.displayAmount = this.formatAmount(this.amount); - } else if (typeof currentValue === 'number') { - // Handle numeric values when currency selector is disabled - this.selectedCurrency = this.defaultCurrency; - this._applyCurrencyDecimalPlaces(); - this.amount = this._fromMinorUnits(currentValue); - this.displayAmount = this.formatAmount(this.amount); - } - } else { - this.selectedCurrency = this.defaultCurrency; - this._applyCurrencyDecimalPlaces(); - this.amount = ''; - this.displayAmount = ''; - } - } - - private parseStringValue(stringValue: string): void { - // Try to parse formats like "100.50 USD", "USD 100.50", "$100.50", "€100,50" - const currencyMatch = stringValue.match(/([A-Z]{3})/); - const numberMatch = stringValue.match(/([\d,.-]+)/); - - if (currencyMatch) { - this.selectedCurrency = currencyMatch[1]; - } else { - // Try to detect currency by symbol - const currency = this.currencies.find((c) => stringValue.includes(c.symbol)); - if (currency) { - this.selectedCurrency = currency.code; - } else { - this.selectedCurrency = this.defaultCurrency; - } - } - - this._applyCurrencyDecimalPlaces(); - - if (numberMatch) { - const cleanNumber = numberMatch[1].replace(/,/g, ''); - const parsed = parseFloat(cleanNumber); - this.amount = Number.isNaN(parsed) ? '' : this._fromMinorUnits(parsed); - this.displayAmount = this.formatAmount(this.amount); - } else { - this.amount = ''; - this.displayAmount = ''; - } - } - - onCurrencyChange(): void { - if (this.cents) { - this._applyCurrencyDecimalPlaces(); - if (this.amount !== '' && this.amount !== null && this.amount !== undefined) { - const numericAmount = typeof this.amount === 'string' ? parseFloat(this.amount) : this.amount; - if (!Number.isNaN(numericAmount)) { - const rounded = parseFloat(numericAmount.toFixed(this.decimalPlaces)); - this.amount = rounded; - this.displayAmount = this.formatAmount(rounded); - } - } - } - this.updateValue(); - } - - onAmountChange(): void { - // Clean and validate the input - let cleanValue = this.displayAmount.replace(/[^\d.-]/g, ''); - - // Handle negative values - if (!this.allowNegative) { - cleanValue = cleanValue.replace(/-/g, ''); - } - - // Parse the number - const numericValue = parseFloat(cleanValue); - - if (!Number.isNaN(numericValue)) { - this.amount = numericValue; - // Don't reformat while user is typing to preserve focus - if (this.displayAmount !== cleanValue) { - this.displayAmount = cleanValue; - } - } else if (cleanValue === '' || cleanValue === '-') { - this.amount = ''; - this.displayAmount = cleanValue; - } else { - // Invalid input, revert to previous value - this.displayAmount = this.formatAmount(this.amount); - } - - this.updateValue(); - } - - onAmountBlur(): void { - // Format the amount when user leaves the field - if (this.amount !== '' && this.amount !== null && this.amount !== undefined) { - this.displayAmount = this.formatAmount(this.amount); - } - } - - public formatAmount(amount: number | string): string { - if (amount === '' || amount === null || amount === undefined) { - return ''; - } - - const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount; - - if (Number.isNaN(numericAmount)) { - return ''; - } - - return numericAmount.toFixed(this.decimalPlaces); - } - - public updateValue(): void { - if (this.amount === '' || this.amount === null || this.amount === undefined) { - this.value.set(''); - } else { - const storedAmount = this._toMinorUnits(); - if (this.showCurrencySelector) { - // Store as object with amount and currency when selector is enabled - this.value.set({ - amount: storedAmount, - currency: this.selectedCurrency, - }); - } else { - // Store only the numeric amount when currency selector is disabled - this.value.set(storedAmount); - } - } - - this.onFieldChange.emit(this.value()); - } - - get selectedCurrencyData(): Money { - return this.currencies.find((c) => c.code === this.selectedCurrency) || this.currencies[0]; - } - - get placeholder(): string { - const currency = this.selectedCurrencyData; - return `Enter amount in ${currency.name}`; - } - - get displayValue(): string { - if (!this.amount && this.amount !== 0) { - return ''; - } - - const currency = this.selectedCurrencyData; - const formattedAmount = this.formatAmount(this.amount); - - return `${currency.symbol}${formattedAmount}`; - } - - displayCurrencyFn(currency: Money): string { - return currency ? `${currency.flag || ''} ${currency.code} - ${currency.name}` : ''; - } - - private _applyCurrencyDecimalPlaces(): void { - if (this.cents) { - this.decimalPlaces = getCurrencyDecimalPlaces(this.selectedCurrency); - } - } - - private _fromMinorUnits(stored: number | string): number | string { - if (!this.cents) { - return stored; - } - const numeric = typeof stored === 'string' ? parseFloat(stored) : stored; - if (Number.isNaN(numeric)) { - return ''; - } - return numeric / getCurrencyMinorUnitFactor(this.selectedCurrency); - } - - private _toMinorUnits(): number { - const sourceText = - this.displayAmount !== '' && this.displayAmount !== null && this.displayAmount !== undefined - ? String(this.displayAmount).replace(/[^\d.-]/g, '') - : typeof this.amount === 'string' - ? this.amount - : String(this.amount); - const numeric = parseFloat(sourceText); - if (Number.isNaN(numeric)) { - return 0; - } - if (!this.cents) { - return numeric; - } - return Math.round(numeric * getCurrencyMinorUnitFactor(this.selectedCurrency)); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.css b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.css deleted file mode 100644 index 957c7d47c..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.number-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html deleted file mode 100644 index fac966063..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.spec.ts deleted file mode 100644 index f65c35168..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { NumberEditComponent } from './number.component'; - -describe('NumberEditComponent', () => { - let component: NumberEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule, NumberEditComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumberEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.stories.ts deleted file mode 100644 index c34e6ff5e..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { NumberEditComponent } from './number.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Number', - component: NumberEditComponent, - args: { - key: 'field_name', - label: 'Number Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 42, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts deleted file mode 100644 index e29c7c9de..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/number/number.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-number', - templateUrl: './number.component.html', - styleUrls: ['./number.component.css'], - imports: [MatFormFieldModule, MatInputModule, FormsModule], -}) -export class NumberEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - static type = 'number'; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.css b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.css deleted file mode 100644 index 1796f8a97..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.password-form-field { - width: 100%; -} - -.password-checkbox { - margin-bottom: 20px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html deleted file mode 100644 index f504a012a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - {{normalizedLabel()}} - - To keep password the same keep this field blank. - - - Clear password - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts deleted file mode 100644 index c4d097039..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { PasswordEditComponent } from './password.component'; - -describe('PasswordEditComponent', () => { - let component: PasswordEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PasswordEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should send onChange event with new null value if user clear password', () => { - component.clearPassword = true; - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onClearPasswordChange(); - expect(event).toHaveBeenCalledWith(null); - }); - - describe('ngOnInit', () => { - it('should reset masked password value to empty string', () => { - fixture.componentRef.setInput('value', '***'); - component.ngOnInit(); - expect(component.value()).toBe(''); - }); - - it('should not emit onFieldChange when password is masked (empty after reset)', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', '***'); - component.ngOnInit(); - expect(event).not.toHaveBeenCalled(); - }); - - it('should emit onFieldChange when password has actual value', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', 'actualPassword'); - component.ngOnInit(); - expect(event).toHaveBeenCalledWith('actualPassword'); - }); - - it('should not emit onFieldChange when password is empty string', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - fixture.componentRef.setInput('value', ''); - component.ngOnInit(); - expect(event).not.toHaveBeenCalled(); - }); - }); - - describe('onPasswordChange', () => { - it('should emit onFieldChange when password has value', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onPasswordChange('newPassword'); - expect(event).toHaveBeenCalledWith('newPassword'); - }); - - it('should not emit onFieldChange when password is empty string', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onPasswordChange(''); - expect(event).not.toHaveBeenCalled(); - }); - - it('should emit onFieldChange when password is whitespace (actual value)', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.onPasswordChange(' '); - expect(event).toHaveBeenCalledWith(' '); - }); - }); - - describe('onClearPasswordChange', () => { - it('should emit null when clearPassword is true', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.clearPassword = true; - component.onClearPasswordChange(); - expect(event).toHaveBeenCalledWith(null); - }); - - it('should not emit when clearPassword is false', () => { - const event = vi.spyOn(component.onFieldChange, 'emit'); - component.clearPassword = false; - component.onClearPasswordChange(); - expect(event).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.stories.ts deleted file mode 100644 index 04b90ed45..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PasswordEditComponent } from './password.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Password', - component: PasswordEditComponent, - args: { - key: 'field_name', - label: 'Password Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'secretpassword', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts deleted file mode 100644 index 361ab3239..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/password/password.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-password', - templateUrl: './password.component.html', - styleUrls: ['./password.component.css'], - imports: [MatFormFieldModule, MatInputModule, MatCheckboxModule, FormsModule], -}) -export class PasswordEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public clearPassword: boolean; - - ngOnInit(): void { - super.ngOnInit(); - if (this.value() === '***') this.value.set(''); - if (this.value() !== '') { - this.onFieldChange.emit(this.value()); - } - } - - onPasswordChange(newValue: string) { - if (newValue !== '') { - this.onFieldChange.emit(newValue); - } - } - - onClearPasswordChange() { - if (this.clearPassword) this.onFieldChange.emit(null); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.css b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.css deleted file mode 100644 index 68574469d..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.css +++ /dev/null @@ -1,67 +0,0 @@ -.phone-input-container { - display: flex; - gap: 12px; - align-items: flex-start; - width: 100%; -} - -.country-select-field { - flex: 0 0 200px; - min-width: 200px; -} - -.phone-number-field { - flex: 1; - min-width: 0; -} - -.country-option { - display: flex; - align-items: center; - gap: 8px; -} - -.flag { - font-size: 16px; - width: 20px; - text-align: center; -} - -.dial-code { - font-weight: 500; - color: rgba(0, 0, 0, 0.87); - min-width: 40px; -} - -.country-name { - color: rgba(0, 0, 0, 0.6); -} - -.phone-number-field input.invalid { - border-color: #f44336; -} - -@media (max-width: 768px) { - .phone-input-container { - flex-direction: column; - gap: 8px; - } - - .country-select-field { - flex: none; - width: 100%; - } - - .phone-number-field { - width: 100%; - } -} - -/* Dark theme support */ -:host-context(.dark-theme) .dial-code { - color: rgba(255, 255, 255, 0.87); -} - -:host-context(.dark-theme) .country-name { - color: rgba(255, 255, 255, 0.6); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html deleted file mode 100644 index fc1aca099..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.html +++ /dev/null @@ -1,53 +0,0 @@ -
- - Country - - - - @for (country of filteredCountries(); track country.code) { - - - {{country.flag}} - {{country.dialCode}} - {{country.name}} - - - } - - - - - {{normalizedLabel()}} - - @if (selectedCountry && !displayPhoneNumber.startsWith('+')) { - Example: {{getExamplePhoneNumber()}} - } - @if (!selectedCountry || displayPhoneNumber.startsWith('+')) { - Enter international number (e.g., +1234567890) or select country first - } - @if (phoneValidation && !isValidPhoneNumber() && displayPhoneNumber) { - - Invalid phone number format - - } - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts deleted file mode 100644 index c57ca7064..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.spec.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { WidgetStructure } from 'src/app/models/table'; -import { CountryCode, PhoneEditComponent } from './phone.component'; - -describe('PhoneEditComponent', () => { - let component: PhoneEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - PhoneEditComponent, - CommonModule, - ReactiveFormsModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatAutocompleteModule, - NoopAnimationsModule, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PhoneEditComponent); - component = fixture.componentInstance; - - // Set basic required properties - fixture.componentRef.setInput('label', 'Phone'); - fixture.componentRef.setInput('key', 'phone'); - - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('US Phone Number Formatting', () => { - beforeEach(() => { - // Set US as selected country - const usCountry = component.countries.find((c) => c.code === 'US'); - component.selectedCountry = usCountry!; - component.countryControl.setValue(usCountry!); - component.initializeFormatter(); - }); - - it('should format US phone number in E164 format when user enters local number', () => { - const localNumber = '(202) 456-1111'; - component.displayPhoneNumber = localNumber; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+12024561111'); - }); - - it('should format US phone number in E164 format when user enters raw digits', () => { - const rawDigits = '2024561111'; - component.displayPhoneNumber = rawDigits; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+12024561111'); - }); - - it('should handle US phone number with different formatting', () => { - const formattedNumber = '202.456.1111'; - component.displayPhoneNumber = formattedNumber; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+12024561111'); - }); - - it('should handle US phone number with country code already included', () => { - const withCountryCode = '+1 202 456 1111'; - component.displayPhoneNumber = withCountryCode; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+12024561111'); - }); - - it('should not format invalid US phone number', () => { - const invalidNumber = '123'; - component.displayPhoneNumber = invalidNumber; - - component.onPhoneNumberChange(); - - // Should either be empty or the cleaned input, but not a malformed international number - expect(component.value()).not.toMatch(/^\+1123$/); - }); - }); - - describe('International Phone Number Formatting', () => { - it('should format UK phone number in E164 format', () => { - const ukCountry = component.countries.find((c) => c.code === 'GB'); - component.selectedCountry = ukCountry!; - component.countryControl.setValue(ukCountry!); - component.initializeFormatter(); - - const localNumber = '020 7946 0958'; - component.displayPhoneNumber = localNumber; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+442079460958'); - }); - - it('should format German phone number in E164 format', () => { - const deCountry = component.countries.find((c) => c.code === 'DE'); - component.selectedCountry = deCountry!; - component.countryControl.setValue(deCountry!); - component.initializeFormatter(); - - const localNumber = '030 12345678'; - component.displayPhoneNumber = localNumber; - - component.onPhoneNumberChange(); - - expect(component.value()).toBe('+493012345678'); - }); - }); - - describe('Phone Number Validation', () => { - beforeEach(() => { - component.phoneValidation = true; - }); - - it('should validate US phone number as valid', () => { - const usCountry = component.countries.find((c) => c.code === 'US'); - component.selectedCountry = usCountry!; - component.displayPhoneNumber = '(202) 456-1111'; - - const isValid = component.isValidPhoneNumber(); - - expect(isValid).toBe(true); - }); - - it('should validate international phone number as valid', () => { - component.displayPhoneNumber = '+442079460958'; - - const isValid = component.isValidPhoneNumber(); - - expect(isValid).toBe(true); - }); - - it('should validate invalid phone number as invalid', () => { - const usCountry = component.countries.find((c) => c.code === 'US'); - component.selectedCountry = usCountry!; - component.displayPhoneNumber = '123'; - - const isValid = component.isValidPhoneNumber(); - - expect(isValid).toBe(false); - }); - - it('should treat empty phone number as valid when validation is enabled', () => { - component.displayPhoneNumber = ''; - - const isValid = component.isValidPhoneNumber(); - - expect(isValid).toBe(true); // Empty is valid, let required validation handle it - }); - }); - - describe('Country Detection', () => { - it('should detect country from international number', () => { - const internationalNumber = '+442079460958'; - component.displayPhoneNumber = internationalNumber; - - component.onPhoneNumberChange(); - - expect(component.selectedCountry.code).toBe('GB'); - expect((component.countryControl.value as CountryCode)?.code).toBe('GB'); - }); - - it('should detect US from +1 number', () => { - const usInternationalNumber = '+12024561111'; - component.displayPhoneNumber = usInternationalNumber; - - component.onPhoneNumberChange(); - - expect(component.selectedCountry.code).toBe('US'); - expect((component.countryControl.value as CountryCode)?.code).toBe('US'); - }); - }); - - describe('Autocomplete Functionality', () => { - it('should filter countries by name', () => { - const filtered = component._filterCountries('United'); - - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.some((country) => country.name.includes('United'))).toBe(true); - }); - - it('should filter countries by code', () => { - const filtered = component._filterCountries('US'); - - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.some((country) => country.code === 'US')).toBe(true); - }); - - it('should filter countries by dial code', () => { - const filtered = component._filterCountries('+1'); - - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.some((country) => country.dialCode === '+1')).toBe(true); - }); - - it('should display country with flag, name and dial code', () => { - const usCountry = component.countries.find((c) => c.code === 'US')!; - - const displayText = component.displayCountryFn(usCountry); - - expect(displayText).toContain('🇺🇸'); - expect(displayText).toContain('United States'); - expect(displayText).toContain('+1'); - }); - - it('should handle country selection', () => { - const ukCountry = component.countries.find((c) => c.code === 'GB')!; - - component.onCountrySelected(ukCountry); - - expect(component.selectedCountry).toBe(ukCountry); - expect(component.formatter).toBeDefined(); - }); - }); - - describe('Example Phone Numbers', () => { - it('should return correct example for US', () => { - const usCountry = component.countries.find((c) => c.code === 'US')!; - component.selectedCountry = usCountry; - - const example = component.getExamplePhoneNumber(); - - expect(example).toBe('(202) 456-1111'); - }); - - it('should return correct example for UK', () => { - const ukCountry = component.countries.find((c) => c.code === 'GB')!; - component.selectedCountry = ukCountry; - - const example = component.getExamplePhoneNumber(); - - expect(example).toBe('020 7946 0958'); - }); - - it('should return fallback example for unknown country', () => { - component.selectedCountry = { code: 'XX', name: 'Unknown', dialCode: '+999', flag: '🏳️' }; - - const example = component.getExamplePhoneNumber(); - - expect(example).toBe('+999 123 4567'); - }); - }); - - describe('Edge Cases and Error Handling', () => { - it('should handle malformed phone numbers gracefully', () => { - component.displayPhoneNumber = 'not-a-phone-number'; - - expect(() => component.onPhoneNumberChange()).not.toThrow(); - }); - - it('should handle empty selected country', () => { - component.selectedCountry = null as unknown as CountryCode; - component.displayPhoneNumber = '5551234567'; - - expect(() => component.onPhoneNumberChange()).not.toThrow(); - }); - - it('should emit field change events', () => { - vi.spyOn(component.onFieldChange, 'emit'); - - const usCountry = component.countries.find((c) => c.code === 'US'); - component.selectedCountry = usCountry!; - component.displayPhoneNumber = '5551234567'; - - component.onPhoneNumberChange(); - - expect(component.onFieldChange.emit).toHaveBeenCalledWith(component.value()); - }); - }); - - describe('Widget Configuration', () => { - it('should configure from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - preferred_countries: ['CA', 'GB'], - enable_placeholder: false, - phone_validation: false, - }, - } as Partial as WidgetStructure); - - component.configureFromWidgetParams(); - - expect(component.preferredCountries).toEqual(['CA', 'GB']); - expect(component.enablePlaceholder).toBe(false); - expect(component.phoneValidation).toBe(false); - }); - - it('should handle missing widget params', () => { - fixture.componentRef.setInput('widgetStructure', {} as Partial as WidgetStructure); - - expect(() => component.configureFromWidgetParams()).not.toThrow(); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.stories.ts deleted file mode 100644 index cd858dffb..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PhoneEditComponent } from './phone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Phone', - component: PhoneEditComponent, - args: { - key: 'field_name', - label: 'Phone Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '+1234567890', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts deleted file mode 100644 index d07321b22..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/phone/phone.component.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, model, OnInit } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { AsYouType, CountryCode as LibPhoneCountryCode, parsePhoneNumber } from 'libphonenumber-js'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -export interface CountryCode { - code: string; - name: string; - dialCode: string; - flag: string; -} - -@Component({ - selector: 'app-edit-phone', - templateUrl: './phone.component.html', - styleUrls: ['./phone.component.css'], - imports: [ - CommonModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatAutocompleteModule, - FormsModule, - ReactiveFormsModule, - ], -}) -export class PhoneEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(''); - - static type = 'phone'; - - preferredCountries: string[] = ['US', 'GB']; - enablePlaceholder: boolean = true; - phoneValidation: boolean = true; - - selectedCountry: CountryCode; - phoneNumber: string = ''; - displayPhoneNumber: string = ''; - formatter: AsYouType | null = null; - - countryControl = new FormControl(null); - - private _countryControlValue = toSignal(this.countryControl.valueChanges, { - initialValue: null as CountryCode | string | null, - }); - - filteredCountries = computed(() => { - const value = this._countryControlValue(); - if (typeof value === 'string') { - return this._filterCountries(value); - } else if (value && typeof value === 'object') { - return this.sortedCountries; - } - return this.sortedCountries; - }); - - countries: CountryCode[] = [ - { code: 'AF', name: 'Afghanistan', dialCode: '+93', flag: '\u{1F1E6}\u{1F1EB}' }, - { code: 'AL', name: 'Albania', dialCode: '+355', flag: '\u{1F1E6}\u{1F1F1}' }, - { code: 'DZ', name: 'Algeria', dialCode: '+213', flag: '\u{1F1E9}\u{1F1FF}' }, - { code: 'AS', name: 'American Samoa', dialCode: '+1684', flag: '\u{1F1E6}\u{1F1F8}' }, - { code: 'AD', name: 'Andorra', dialCode: '+376', flag: '\u{1F1E6}\u{1F1E9}' }, - { code: 'AO', name: 'Angola', dialCode: '+244', flag: '\u{1F1E6}\u{1F1F4}' }, - { code: 'AI', name: 'Anguilla', dialCode: '+1264', flag: '\u{1F1E6}\u{1F1EE}' }, - { code: 'AQ', name: 'Antarctica', dialCode: '+672', flag: '\u{1F1E6}\u{1F1F6}' }, - { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268', flag: '\u{1F1E6}\u{1F1EC}' }, - { code: 'AR', name: 'Argentina', dialCode: '+54', flag: '\u{1F1E6}\u{1F1F7}' }, - { code: 'AM', name: 'Armenia', dialCode: '+374', flag: '\u{1F1E6}\u{1F1F2}' }, - { code: 'AW', name: 'Aruba', dialCode: '+297', flag: '\u{1F1E6}\u{1F1FC}' }, - { code: 'AU', name: 'Australia', dialCode: '+61', flag: '\u{1F1E6}\u{1F1FA}' }, - { code: 'AT', name: 'Austria', dialCode: '+43', flag: '\u{1F1E6}\u{1F1F9}' }, - { code: 'AZ', name: 'Azerbaijan', dialCode: '+994', flag: '\u{1F1E6}\u{1F1FF}' }, - { code: 'BS', name: 'Bahamas', dialCode: '+1242', flag: '\u{1F1E7}\u{1F1F8}' }, - { code: 'BH', name: 'Bahrain', dialCode: '+973', flag: '\u{1F1E7}\u{1F1ED}' }, - { code: 'BD', name: 'Bangladesh', dialCode: '+880', flag: '\u{1F1E7}\u{1F1E9}' }, - { code: 'BB', name: 'Barbados', dialCode: '+1246', flag: '\u{1F1E7}\u{1F1E7}' }, - { code: 'BY', name: 'Belarus', dialCode: '+375', flag: '\u{1F1E7}\u{1F1FE}' }, - { code: 'BE', name: 'Belgium', dialCode: '+32', flag: '\u{1F1E7}\u{1F1EA}' }, - { code: 'BZ', name: 'Belize', dialCode: '+501', flag: '\u{1F1E7}\u{1F1FF}' }, - { code: 'BJ', name: 'Benin', dialCode: '+229', flag: '\u{1F1E7}\u{1F1EF}' }, - { code: 'BM', name: 'Bermuda', dialCode: '+1441', flag: '\u{1F1E7}\u{1F1F2}' }, - { code: 'BT', name: 'Bhutan', dialCode: '+975', flag: '\u{1F1E7}\u{1F1F9}' }, - { code: 'BO', name: 'Bolivia', dialCode: '+591', flag: '\u{1F1E7}\u{1F1F4}' }, - { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387', flag: '\u{1F1E7}\u{1F1E6}' }, - { code: 'BW', name: 'Botswana', dialCode: '+267', flag: '\u{1F1E7}\u{1F1FC}' }, - { code: 'BR', name: 'Brazil', dialCode: '+55', flag: '\u{1F1E7}\u{1F1F7}' }, - { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246', flag: '\u{1F1EE}\u{1F1F4}' }, - { code: 'BN', name: 'Brunei', dialCode: '+673', flag: '\u{1F1E7}\u{1F1F3}' }, - { code: 'BG', name: 'Bulgaria', dialCode: '+359', flag: '\u{1F1E7}\u{1F1EC}' }, - { code: 'BF', name: 'Burkina Faso', dialCode: '+226', flag: '\u{1F1E7}\u{1F1EB}' }, - { code: 'BI', name: 'Burundi', dialCode: '+257', flag: '\u{1F1E7}\u{1F1EE}' }, - { code: 'KH', name: 'Cambodia', dialCode: '+855', flag: '\u{1F1F0}\u{1F1ED}' }, - { code: 'CM', name: 'Cameroon', dialCode: '+237', flag: '\u{1F1E8}\u{1F1F2}' }, - { code: 'CA', name: 'Canada', dialCode: '+1', flag: '\u{1F1E8}\u{1F1E6}' }, - { code: 'CV', name: 'Cape Verde', dialCode: '+238', flag: '\u{1F1E8}\u{1F1FB}' }, - { code: 'KY', name: 'Cayman Islands', dialCode: '+1345', flag: '\u{1F1F0}\u{1F1FE}' }, - { code: 'CF', name: 'Central African Republic', dialCode: '+236', flag: '\u{1F1E8}\u{1F1EB}' }, - { code: 'TD', name: 'Chad', dialCode: '+235', flag: '\u{1F1F9}\u{1F1E9}' }, - { code: 'CL', name: 'Chile', dialCode: '+56', flag: '\u{1F1E8}\u{1F1F1}' }, - { code: 'CN', name: 'China', dialCode: '+86', flag: '\u{1F1E8}\u{1F1F3}' }, - { code: 'CO', name: 'Colombia', dialCode: '+57', flag: '\u{1F1E8}\u{1F1F4}' }, - { code: 'CR', name: 'Costa Rica', dialCode: '+506', flag: '\u{1F1E8}\u{1F1F7}' }, - { code: 'HR', name: 'Croatia', dialCode: '+385', flag: '\u{1F1ED}\u{1F1F7}' }, - { code: 'CU', name: 'Cuba', dialCode: '+53', flag: '\u{1F1E8}\u{1F1FA}' }, - { code: 'CY', name: 'Cyprus', dialCode: '+357', flag: '\u{1F1E8}\u{1F1FE}' }, - { code: 'CZ', name: 'Czech Republic', dialCode: '+420', flag: '\u{1F1E8}\u{1F1FF}' }, - { code: 'DK', name: 'Denmark', dialCode: '+45', flag: '\u{1F1E9}\u{1F1F0}' }, - { code: 'DO', name: 'Dominican Republic', dialCode: '+1', flag: '\u{1F1E9}\u{1F1F4}' }, - { code: 'EC', name: 'Ecuador', dialCode: '+593', flag: '\u{1F1EA}\u{1F1E8}' }, - { code: 'EG', name: 'Egypt', dialCode: '+20', flag: '\u{1F1EA}\u{1F1EC}' }, - { code: 'SV', name: 'El Salvador', dialCode: '+503', flag: '\u{1F1F8}\u{1F1FB}' }, - { code: 'EE', name: 'Estonia', dialCode: '+372', flag: '\u{1F1EA}\u{1F1EA}' }, - { code: 'ET', name: 'Ethiopia', dialCode: '+251', flag: '\u{1F1EA}\u{1F1F9}' }, - { code: 'FI', name: 'Finland', dialCode: '+358', flag: '\u{1F1EB}\u{1F1EE}' }, - { code: 'FR', name: 'France', dialCode: '+33', flag: '\u{1F1EB}\u{1F1F7}' }, - { code: 'DE', name: 'Germany', dialCode: '+49', flag: '\u{1F1E9}\u{1F1EA}' }, - { code: 'GH', name: 'Ghana', dialCode: '+233', flag: '\u{1F1EC}\u{1F1ED}' }, - { code: 'GR', name: 'Greece', dialCode: '+30', flag: '\u{1F1EC}\u{1F1F7}' }, - { code: 'GT', name: 'Guatemala', dialCode: '+502', flag: '\u{1F1EC}\u{1F1F9}' }, - { code: 'HN', name: 'Honduras', dialCode: '+504', flag: '\u{1F1ED}\u{1F1F3}' }, - { code: 'HK', name: 'Hong Kong', dialCode: '+852', flag: '\u{1F1ED}\u{1F1F0}' }, - { code: 'HU', name: 'Hungary', dialCode: '+36', flag: '\u{1F1ED}\u{1F1FA}' }, - { code: 'IS', name: 'Iceland', dialCode: '+354', flag: '\u{1F1EE}\u{1F1F8}' }, - { code: 'IN', name: 'India', dialCode: '+91', flag: '\u{1F1EE}\u{1F1F3}' }, - { code: 'ID', name: 'Indonesia', dialCode: '+62', flag: '\u{1F1EE}\u{1F1E9}' }, - { code: 'IR', name: 'Iran', dialCode: '+98', flag: '\u{1F1EE}\u{1F1F7}' }, - { code: 'IQ', name: 'Iraq', dialCode: '+964', flag: '\u{1F1EE}\u{1F1F6}' }, - { code: 'IE', name: 'Ireland', dialCode: '+353', flag: '\u{1F1EE}\u{1F1EA}' }, - { code: 'IL', name: 'Israel', dialCode: '+972', flag: '\u{1F1EE}\u{1F1F1}' }, - { code: 'IT', name: 'Italy', dialCode: '+39', flag: '\u{1F1EE}\u{1F1F9}' }, - { code: 'JM', name: 'Jamaica', dialCode: '+1876', flag: '\u{1F1EF}\u{1F1F2}' }, - { code: 'JP', name: 'Japan', dialCode: '+81', flag: '\u{1F1EF}\u{1F1F5}' }, - { code: 'JO', name: 'Jordan', dialCode: '+962', flag: '\u{1F1EF}\u{1F1F4}' }, - { code: 'KZ', name: 'Kazakhstan', dialCode: '+7', flag: '\u{1F1F0}\u{1F1FF}' }, - { code: 'KE', name: 'Kenya', dialCode: '+254', flag: '\u{1F1F0}\u{1F1EA}' }, - { code: 'KR', name: 'South Korea', dialCode: '+82', flag: '\u{1F1F0}\u{1F1F7}' }, - { code: 'KW', name: 'Kuwait', dialCode: '+965', flag: '\u{1F1F0}\u{1F1FC}' }, - { code: 'LV', name: 'Latvia', dialCode: '+371', flag: '\u{1F1F1}\u{1F1FB}' }, - { code: 'LB', name: 'Lebanon', dialCode: '+961', flag: '\u{1F1F1}\u{1F1E7}' }, - { code: 'LT', name: 'Lithuania', dialCode: '+370', flag: '\u{1F1F1}\u{1F1F9}' }, - { code: 'LU', name: 'Luxembourg', dialCode: '+352', flag: '\u{1F1F1}\u{1F1FA}' }, - { code: 'MY', name: 'Malaysia', dialCode: '+60', flag: '\u{1F1F2}\u{1F1FE}' }, - { code: 'MX', name: 'Mexico', dialCode: '+52', flag: '\u{1F1F2}\u{1F1FD}' }, - { code: 'MA', name: 'Morocco', dialCode: '+212', flag: '\u{1F1F2}\u{1F1E6}' }, - { code: 'NL', name: 'Netherlands', dialCode: '+31', flag: '\u{1F1F3}\u{1F1F1}' }, - { code: 'NZ', name: 'New Zealand', dialCode: '+64', flag: '\u{1F1F3}\u{1F1FF}' }, - { code: 'NG', name: 'Nigeria', dialCode: '+234', flag: '\u{1F1F3}\u{1F1EC}' }, - { code: 'NO', name: 'Norway', dialCode: '+47', flag: '\u{1F1F3}\u{1F1F4}' }, - { code: 'PK', name: 'Pakistan', dialCode: '+92', flag: '\u{1F1F5}\u{1F1F0}' }, - { code: 'PA', name: 'Panama', dialCode: '+507', flag: '\u{1F1F5}\u{1F1E6}' }, - { code: 'PE', name: 'Peru', dialCode: '+51', flag: '\u{1F1F5}\u{1F1EA}' }, - { code: 'PH', name: 'Philippines', dialCode: '+63', flag: '\u{1F1F5}\u{1F1ED}' }, - { code: 'PL', name: 'Poland', dialCode: '+48', flag: '\u{1F1F5}\u{1F1F1}' }, - { code: 'PT', name: 'Portugal', dialCode: '+351', flag: '\u{1F1F5}\u{1F1F9}' }, - { code: 'PR', name: 'Puerto Rico', dialCode: '+1787', flag: '\u{1F1F5}\u{1F1F7}' }, - { code: 'QA', name: 'Qatar', dialCode: '+974', flag: '\u{1F1F6}\u{1F1E6}' }, - { code: 'RO', name: 'Romania', dialCode: '+40', flag: '\u{1F1F7}\u{1F1F4}' }, - { code: 'RU', name: 'Russia', dialCode: '+7', flag: '\u{1F1F7}\u{1F1FA}' }, - { code: 'SA', name: 'Saudi Arabia', dialCode: '+966', flag: '\u{1F1F8}\u{1F1E6}' }, - { code: 'RS', name: 'Serbia', dialCode: '+381', flag: '\u{1F1F7}\u{1F1F8}' }, - { code: 'SG', name: 'Singapore', dialCode: '+65', flag: '\u{1F1F8}\u{1F1EC}' }, - { code: 'SK', name: 'Slovakia', dialCode: '+421', flag: '\u{1F1F8}\u{1F1F0}' }, - { code: 'SI', name: 'Slovenia', dialCode: '+386', flag: '\u{1F1F8}\u{1F1EE}' }, - { code: 'ZA', name: 'South Africa', dialCode: '+27', flag: '\u{1F1FF}\u{1F1E6}' }, - { code: 'ES', name: 'Spain', dialCode: '+34', flag: '\u{1F1EA}\u{1F1F8}' }, - { code: 'LK', name: 'Sri Lanka', dialCode: '+94', flag: '\u{1F1F1}\u{1F1F0}' }, - { code: 'SE', name: 'Sweden', dialCode: '+46', flag: '\u{1F1F8}\u{1F1EA}' }, - { code: 'CH', name: 'Switzerland', dialCode: '+41', flag: '\u{1F1E8}\u{1F1ED}' }, - { code: 'TW', name: 'Taiwan', dialCode: '+886', flag: '\u{1F1F9}\u{1F1FC}' }, - { code: 'TH', name: 'Thailand', dialCode: '+66', flag: '\u{1F1F9}\u{1F1ED}' }, - { code: 'TR', name: 'Turkey', dialCode: '+90', flag: '\u{1F1F9}\u{1F1F7}' }, - { code: 'UA', name: 'Ukraine', dialCode: '+380', flag: '\u{1F1FA}\u{1F1E6}' }, - { code: 'AE', name: 'United Arab Emirates', dialCode: '+971', flag: '\u{1F1E6}\u{1F1EA}' }, - { code: 'GB', name: 'United Kingdom', dialCode: '+44', flag: '\u{1F1EC}\u{1F1E7}' }, - { code: 'US', name: 'United States', dialCode: '+1', flag: '\u{1F1FA}\u{1F1F8}' }, - { code: 'UY', name: 'Uruguay', dialCode: '+598', flag: '\u{1F1FA}\u{1F1FE}' }, - { code: 'UZ', name: 'Uzbekistan', dialCode: '+998', flag: '\u{1F1FA}\u{1F1FF}' }, - { code: 'VE', name: 'Venezuela', dialCode: '+58', flag: '\u{1F1FB}\u{1F1EA}' }, - { code: 'VN', name: 'Vietnam', dialCode: '+84', flag: '\u{1F1FB}\u{1F1F3}' }, - { code: 'ZM', name: 'Zambia', dialCode: '+260', flag: '\u{1F1FF}\u{1F1F2}' }, - { code: 'ZW', name: 'Zimbabwe', dialCode: '+263', flag: '\u{1F1FF}\u{1F1FC}' }, - ]; - - ngOnInit(): void { - super.ngOnInit(); - this.configureFromWidgetParams(); - this.initializePhoneNumber(); - } - - configureFromWidgetParams(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - const params = ws.widget_params; - - if (params.preferred_countries && Array.isArray(params.preferred_countries)) { - this.preferredCountries = params.preferred_countries; - } - - if (typeof params.enable_placeholder === 'boolean') { - this.enablePlaceholder = params.enable_placeholder; - } - - if (typeof params.phone_validation === 'boolean') { - this.phoneValidation = params.phone_validation; - } - } - } - - _filterCountries(value: string): CountryCode[] { - const filterValue = value.toLowerCase(); - return this.sortedCountries.filter( - (country) => - country.name.toLowerCase().includes(filterValue) || - country.code.toLowerCase().includes(filterValue) || - country.dialCode.includes(filterValue), - ); - } - - displayCountryFn(country: CountryCode): string { - return country ? `${country.flag} ${country.name} ${country.dialCode}` : ''; - } - - onCountrySelected(country: CountryCode): void { - this.selectedCountry = country; - this.initializeFormatter(); - this.formatAndUpdatePhoneNumber(); - } - - initializeFormatter(): void { - if (this.selectedCountry) { - this.formatter = new AsYouType(this.selectedCountry.code as LibPhoneCountryCode); - } - } - - onCountryChange(): void { - this.initializeFormatter(); - this.formatAndUpdatePhoneNumber(); - } - - onPhoneNumberChange(): void { - if (this.displayPhoneNumber.startsWith('+')) { - this.detectCountryFromInput(); - } else { - this.formatAndUpdatePhoneNumber(); - } - } - - get sortedCountries(): CountryCode[] { - const preferred = this.countries.filter((c) => this.preferredCountries.includes(c.code)); - const others = this.countries.filter((c) => !this.preferredCountries.includes(c.code)); - return [...preferred, ...others]; - } - - get placeholder(): string { - if (!this.enablePlaceholder) return ''; - return this.selectedCountry ? `Phone number for ${this.selectedCountry.name}` : 'Phone number'; - } - - getPhoneNumberPlaceholder(): string { - if (!this.enablePlaceholder) return ''; - if (this.selectedCountry) { - return `Local number or ${this.selectedCountry.dialCode}1234567890`; - } - return 'Enter +1234567890 or select country'; - } - - getExamplePhoneNumber(): string { - if (!this.selectedCountry) return ''; - - const exampleNumbers: { [key: string]: string } = { - US: '(202) 456-1111', - GB: '020 7946 0958', - CA: '(416) 555-1234', - AU: '(02) 1234 5678', - DE: '030 12345678', - FR: '01 23 45 67 89', - }; - - return exampleNumbers[this.selectedCountry.code] || `${this.selectedCountry.dialCode} 123 4567`; - } - - isValidPhoneNumber(): boolean { - if (!this.phoneValidation) return true; - if (!this.displayPhoneNumber) return true; - - try { - let phoneNumber; - - if (this.displayPhoneNumber.startsWith('+')) { - phoneNumber = parsePhoneNumber(this.displayPhoneNumber); - } else if (this.selectedCountry) { - phoneNumber = parsePhoneNumber(this.displayPhoneNumber, this.selectedCountry.code as LibPhoneCountryCode); - } else { - return false; - } - - return phoneNumber ? phoneNumber.isValid() : false; - } catch (_error) { - return false; - } - } - - private initializePhoneNumber(): void { - const currentValue = this.value(); - if (currentValue) { - this.parseExistingPhoneNumber(currentValue); - } else { - this.setDefaultCountry(); - this.displayPhoneNumber = ''; - } - } - - private parseExistingPhoneNumber(fullNumber: string): void { - let phoneNumber; - - try { - phoneNumber = parsePhoneNumber(fullNumber); - } catch (_error) { - // Will try with default country below - } - - if (!phoneNumber || !phoneNumber.country) { - try { - const defaultCountryCode = this.preferredCountries[0] || 'US'; - phoneNumber = parsePhoneNumber(fullNumber, defaultCountryCode as LibPhoneCountryCode); - } catch (error) { - console.warn('Failed to parse with default country as well:', error); - } - } - - if (phoneNumber?.country) { - const country = this.countries.find((c) => c.code === phoneNumber.country); - if (country) { - this.selectedCountry = country; - this.countryControl.setValue(country); - this.phoneNumber = phoneNumber.nationalNumber; - this.displayPhoneNumber = phoneNumber.formatNational(); - this.initializeFormatter(); - return; - } else { - console.warn('Country not found in list:', phoneNumber.country); - } - } - - this.setDefaultCountry(); - this.phoneNumber = fullNumber.replace(/\D/g, ''); - - if (this.formatter && this.phoneNumber) { - this.formatter.reset(); - this.displayPhoneNumber = this.formatter.input(this.phoneNumber); - } else { - this.displayPhoneNumber = fullNumber; - } - } - - private setDefaultCountry(): void { - this.selectedCountry = this.countries.find((c) => c.code === this.preferredCountries[0]) || this.countries[0]; - this.countryControl.setValue(this.selectedCountry); - this.initializeFormatter(); - } - - private formatAndUpdatePhoneNumber(): void { - if (!this.displayPhoneNumber) { - this.phoneNumber = ''; - this.value.set(''); - this.onFieldChange.emit(this.value()); - return; - } - - if (this.formatter && !this.displayPhoneNumber.startsWith('+')) { - this.formatter.reset(); - const formatted = this.formatter.input(this.displayPhoneNumber); - this.displayPhoneNumber = formatted; - - this.phoneNumber = this.displayPhoneNumber.replace(/\D/g, ''); - } else { - this.phoneNumber = this.displayPhoneNumber.replace(/\D/g, ''); - } - - this.updateFullPhoneNumber(); - } - - private detectCountryFromInput(): void { - if (!this.displayPhoneNumber.startsWith('+')) { - return; - } - - try { - const phoneNumber = parsePhoneNumber(this.displayPhoneNumber); - if (phoneNumber?.country) { - const detectedCountry = this.countries.find((c) => c.code === phoneNumber.country); - if (detectedCountry) { - this.selectedCountry = detectedCountry; - this.countryControl.setValue(detectedCountry); - this.phoneNumber = phoneNumber.nationalNumber; - this.displayPhoneNumber = phoneNumber.formatNational(); - this.initializeFormatter(); - this.updateFullPhoneNumber(); - return; - } - } - } catch (error) { - console.warn('Could not detect country from input:', this.displayPhoneNumber, error); - } - - this.phoneNumber = this.displayPhoneNumber.replace(/\D/g, ''); - this.updateFullPhoneNumber(); - } - - private updateFullPhoneNumber(): void { - if (!this.displayPhoneNumber && !this.phoneNumber) { - this.value.set(''); - this.onFieldChange.emit(this.value()); - return; - } - - try { - let phoneNumber; - - if (this.displayPhoneNumber.startsWith('+')) { - phoneNumber = parsePhoneNumber(this.displayPhoneNumber); - } else if (this.selectedCountry && this.displayPhoneNumber) { - phoneNumber = parsePhoneNumber(this.displayPhoneNumber, this.selectedCountry.code as LibPhoneCountryCode); - } - - if (phoneNumber?.isValid()) { - this.value.set(phoneNumber.number); - } else { - this.value.set(this.displayPhoneNumber.replace(/\s/g, '')); - } - } catch (error) { - console.warn('Error formatting phone number:', error); - this.value.set(this.displayPhoneNumber.replace(/\s/g, '')); - } - - this.onFieldChange.emit(this.value()); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.css b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.css deleted file mode 100644 index 7ab52d4e1..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.css +++ /dev/null @@ -1,6 +0,0 @@ -.mat-mdc-form-field { - max-width: 260px; - min-width: 150px; - width: 48vw; - display: block; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html deleted file mode 100644 index d37548790..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.html +++ /dev/null @@ -1,12 +0,0 @@ -@if (value()) { - - {{normalizedLabel()}} X coordinate - - - - {{normalizedLabel()}} Y coordinate - - -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.spec.ts deleted file mode 100644 index 0d3b040ec..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { PointEditComponent } from './point.component'; - -describe('PointEditComponent', () => { - let component: PointEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PointEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PointEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.stories.ts deleted file mode 100644 index 8c66cda56..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PointEditComponent } from './point.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'point', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Point', - component: PointEditComponent, - args: { - key: 'field_name', - label: 'Point Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '(40.7128, -74.0060)', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts deleted file mode 100644 index c4043f6a9..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/point/point.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-point', - templateUrl: './point.component.html', - styleUrls: ['./point.component.css'], - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule], -}) -export class PointEditComponent extends BaseEditFieldComponent { - readonly value = model(); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.css b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.css deleted file mode 100644 index 4ccd92ebf..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.css +++ /dev/null @@ -1,120 +0,0 @@ -.range-edit-container { - width: 100%; - display: flex; - flex-direction: column; - gap: 4px; - padding-bottom: 28px; -} - -.range-label { - font-size: 14px; - color: rgba(0, 0, 0, 0.87); - margin-bottom: 8px; - display: block; -} - -.range-labels { - display: flex; - justify-content: space-between; - font-size: 12px; - color: rgba(0, 0, 0, 0.6); - padding: 0 2px; -} - -.range-current { - font-weight: 600; - color: rgba(0, 0, 0, 0.87); -} - -.range-input { - width: 100%; - height: 6px; - -webkit-appearance: none; - appearance: none; - background: transparent; - outline: none; - cursor: pointer; -} - -.range-input::-webkit-slider-track { - width: 100%; - height: 6px; - background: #e0e0e0; - border-radius: 3px; -} - -.range-input::-moz-range-track { - width: 100%; - height: 6px; - background: #e0e0e0; - border-radius: 3px; -} - -.range-input::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 18px; - height: 18px; - background: #1976d2; - border-radius: 50%; - cursor: pointer; - margin-top: -6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: all 0.2s ease; -} - -.range-input::-moz-range-thumb { - width: 18px; - height: 18px; - background: #1976d2; - border-radius: 50%; - cursor: pointer; - border: none; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - transition: all 0.2s ease; -} - -.range-input::-webkit-slider-thumb:hover { - transform: scale(1.1); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); -} - -.range-input::-moz-range-thumb:hover { - transform: scale(1.1); - box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3); -} - -.range-input:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.range-input:disabled::-webkit-slider-thumb { - cursor: not-allowed; -} - -.range-input:disabled::-moz-range-thumb { - cursor: not-allowed; -} - -@media (prefers-color-scheme: dark) { - .range-label { - color: rgba(255, 255, 255, 0.87); - } - - .range-labels { - color: rgba(255, 255, 255, 0.6); - } - - .range-current { - color: rgba(255, 255, 255, 0.87); - } - - .range-input::-webkit-slider-track { - background: #424242; - } - - .range-input::-moz-range-track { - background: #424242; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html deleted file mode 100644 index 52d9eba05..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
- {{ normalizedLabel() }} {{ required() ? '*' : '' }} -
- {{ min }} - {{ value() || min }} - {{ max }} -
- -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts deleted file mode 100644 index 972d45f3f..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RangeEditComponent } from './range.component'; - -describe('RangeEditComponent', () => { - let component: RangeEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RangeEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RangeEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have default min, max, and step values', () => { - expect(component.min).toBe(0); - expect(component.max).toBe(100); - expect(component.step).toBe(1); - }); - - it('should parse widget params on init', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { min: 10, max: 200, step: 5 }, - } as any); - component.ngOnInit(); - expect(component.min).toBe(10); - expect(component.max).toBe(200); - expect(component.step).toBe(5); - }); - - it('should parse widget params on changes', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { min: 5, max: 50, step: 2 }, - } as any); - component.ngOnChanges(); - expect(component.min).toBe(5); - expect(component.max).toBe(50); - expect(component.step).toBe(2); - }); - - it('should keep defaults when widget_params is undefined', () => { - fixture.componentRef.setInput('widgetStructure', undefined); - component.ngOnInit(); - expect(component.min).toBe(0); - expect(component.max).toBe(100); - expect(component.step).toBe(1); - }); - - it('should handle partial widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { min: 20 }, - } as any); - component.ngOnInit(); - expect(component.min).toBe(20); - expect(component.max).toBe(100); - expect(component.step).toBe(1); - }); - - it('should emit onFieldChange when value changes', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.onValueChange(42); - expect(component.value()).toBe(42); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(42); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.stories.ts deleted file mode 100644 index 67cc9d334..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { RangeEditComponent } from './range.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Range', - component: RangeEditComponent, - args: { - key: 'field_name', - label: 'Range Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 50 as any, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts deleted file mode 100644 index ea146c828..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/range/range.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, ElementRef, model, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-range-edit', - templateUrl: './range.component.html', - styleUrls: ['./range.component.css'], - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule], -}) -export class RangeEditComponent extends BaseEditFieldComponent { - @ViewChild('rangeInput') rangeInput: ElementRef; - readonly value = model(); - static type = 'range'; - - public min: number = 0; - public max: number = 100; - public step: number = 1; - - override ngOnInit(): void { - super.ngOnInit(); - this._parseWidgetParams(); - } - - ngOnChanges(): void { - this._parseWidgetParams(); - } - - public onValueChange(newValue: number): void { - this.value.set(newValue); - this.onFieldChange.emit(this.value()); - } - - private _parseWidgetParams(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - try { - const params = ws.widget_params; - if (params.min !== undefined) { - this.min = Number(params.min) || 0; - } - if (params.max !== undefined) { - this.max = Number(params.max) || 100; - } - if (params.step !== undefined) { - this.step = Number(params.step) || 1; - } - } catch (error) { - console.error('Failed to parse widget params:', error); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css deleted file mode 100644 index 6e8be11c8..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.css +++ /dev/null @@ -1,57 +0,0 @@ -.s3-widget { - display: flex; - flex-direction: column; - gap: 12px; -} - -.s3-widget__key { - width: 100%; -} - -.s3-widget__actions { - display: flex; - gap: 8px; -} - -.s3-widget__actions button { - display: flex; - align-items: center; - gap: 4px; -} - -.s3-widget__preview { - display: flex; - align-items: center; - justify-content: center; - min-height: 100px; - border: 1px dashed #ccc; - border-radius: 4px; - padding: 8px; -} - -.s3-widget__thumbnail { - max-width: 200px; - max-height: 150px; - object-fit: contain; - border-radius: 4px; -} - -.s3-widget__file-icon { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - color: #666; -} - -.s3-widget__file-icon mat-icon { - font-size: 48px; - width: 48px; - height: 48px; -} - -.s3-widget__filename { - font-size: 12px; - word-break: break-all; - text-align: center; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html deleted file mode 100644 index 58d556f85..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
- - {{normalizedLabel()}} - - - -
- - - @if (previewUrl()) { - - } -
- - @if (internalValue() || value()) { -
- @if (isLoading()) { - - } @else if (isImage() && previewUrl()) { - Preview - } @else if (previewUrl()) { -
- insert_drive_file - {{displayFilename()}} -
- } -
- } -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts deleted file mode 100644 index 52f9cd4dd..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.spec.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { WidgetStructure } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { S3EditComponent } from './s3.component'; - -describe('S3EditComponent', () => { - let component: S3EditComponent; - let fixture: ComponentFixture; - let fakeS3Service: any; - let fakeConnectionsService: any; - let fakeTablesService: any; - - const mockWidgetStructure: WidgetStructure = { - field_name: 'document', - widget_type: 'S3', - widget_params: { - provider: 'aws', - bucket: 'test-bucket', - prefix: 'uploads/', - region: 'us-east-1', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - }, - name: 'Document Upload', - description: 'Upload documents to S3', - }; - - const mockWidgetStructureStringParams: WidgetStructure = { - field_name: 'document', - widget_type: 'S3', - widget_params: JSON.stringify({ - provider: 'aws', - bucket: 'test-bucket', - prefix: 'uploads/', - region: 'us-east-1', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - }) as any, - name: 'Document Upload', - description: 'Upload documents to S3', - }; - - const mockFileUrlResponse = { - url: 'https://s3.amazonaws.com/bucket/file.pdf?signature=abc123', - key: 'uploads/file.pdf', - expiresIn: 3600, - }; - - const mockUploadResponse = { - key: 'uploads/newfile.pdf', - previewUrl: 'https://s3.amazonaws.com/bucket/uploads/newfile.pdf?preview=true', - }; - - beforeEach(async () => { - fakeS3Service = { - getFileUrl: vi.fn(), - uploadFile: vi.fn(), - } as any; - fakeConnectionsService = { - get currentConnectionID() { - return 'conn-123'; - }, - } as any; - fakeTablesService = { - get currentTableName() { - return 'users'; - }, - } as any; - - await TestBed.configureTestingModule({ - imports: [FormsModule, BrowserAnimationsModule, S3EditComponent], - providers: [ - { provide: S3Service, useValue: fakeS3Service }, - { provide: ConnectionsService, useValue: fakeConnectionsService }, - { provide: TablesService, useValue: fakeTablesService }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(S3EditComponent); - component = fixture.componentInstance; - - fixture.componentRef.setInput('key', 'document'); - fixture.componentRef.setInput('label', 'Document'); - fixture.componentRef.setInput('widgetStructure', mockWidgetStructure); - fixture.componentRef.setInput('rowPrimaryKey', { id: 1 }); - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - describe('ngOnInit', () => { - it('should set connectionId and tableName from services', () => { - fixture.detectChanges(); - - expect((component as any).connectionId).toBe('conn-123'); - expect((component as any).tableName).toBe('users'); - }); - - it('should parse widget params from object', () => { - fixture.componentRef.setInput('widgetStructure', mockWidgetStructure); - fixture.detectChanges(); - - expect(component.params()).toEqual({ - provider: 'aws', - bucket: 'test-bucket', - prefix: 'uploads/', - region: 'us-east-1', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - }); - }); - - it('should parse widget params from string', () => { - fixture.componentRef.setInput('widgetStructure', mockWidgetStructureStringParams); - fixture.detectChanges(); - - expect(component.params()?.bucket).toBe('test-bucket'); - }); - - it('should load preview if value is present', () => { - fixture.componentRef.setInput('value', 'uploads/existing-file.pdf'); - fakeS3Service.getFileUrl.mockResolvedValue(mockFileUrlResponse); - - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).toHaveBeenCalledWith('conn-123', 'users', 'document', { id: 1 }); - }); - - it('should not load preview if value is empty', () => { - fixture.componentRef.setInput('value', ''); - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - }); - - describe('effect-based change detection', () => { - it('should load preview when value changes and no preview exists', async () => { - fixture.detectChanges(); - fakeS3Service.getFileUrl.mockResolvedValue(mockFileUrlResponse); - - fixture.componentRef.setInput('value', 'uploads/new-file.pdf'); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(fakeS3Service.getFileUrl).toHaveBeenCalled(); - }); - - it('should not reload preview if already loading', () => { - fixture.detectChanges(); - component.isLoading.set(true); - fakeS3Service.getFileUrl.mockClear(); - - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - - it('should not reload preview if preview already exists', () => { - fixture.detectChanges(); - component.previewUrl.set('https://example.com/preview'); - fakeS3Service.getFileUrl.mockClear(); - - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - }); - - describe('onFileSelected', () => { - it('should upload file and update value on success', async () => { - fixture.detectChanges(); - fakeS3Service.uploadFile.mockResolvedValue(mockUploadResponse); - - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - const event = { - target: { - files: [file], - }, - } as unknown as Event; - - vi.spyOn(component.onFieldChange, 'emit'); - await component.onFileSelected(event); - await fixture.whenStable(); - - expect(fakeS3Service.uploadFile).toHaveBeenCalledWith('conn-123', 'users', 'document', file); - expect(component.internalValue()).toBe('uploads/newfile.pdf'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('uploads/newfile.pdf'); - }); - - it('should do nothing if no files selected', async () => { - fixture.detectChanges(); - const event = { - target: { - files: [], - }, - } as unknown as Event; - - await component.onFileSelected(event); - - expect(fakeS3Service.uploadFile).not.toHaveBeenCalled(); - }); - - it('should do nothing if files is null', async () => { - fixture.detectChanges(); - const event = { - target: { - files: null, - }, - } as unknown as Event; - - await component.onFileSelected(event); - - expect(fakeS3Service.uploadFile).not.toHaveBeenCalled(); - }); - - it('should set isLoading to true during upload', async () => { - fixture.detectChanges(); - let resolveUpload: (value: { key: string; previewUrl: string }) => void; - const pendingPromise = new Promise<{ key: string; previewUrl: string }>((resolve) => { - resolveUpload = resolve; - }); - fakeS3Service.uploadFile.mockReturnValue(pendingPromise); - - const file = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const event = { target: { files: [file] } } as unknown as Event; - - const uploadPromise = component.onFileSelected(event); - - expect(component.isLoading()).toBe(true); - - resolveUpload!(mockUploadResponse); - await uploadPromise; - }); - - it('should set isLoading to false when upload returns null', async () => { - fixture.detectChanges(); - fakeS3Service.uploadFile.mockResolvedValue(null); - - const file = new File(['test'], 'test.pdf', { type: 'application/pdf' }); - const event = { target: { files: [file] } } as unknown as Event; - - await component.onFileSelected(event); - await fixture.whenStable(); - - expect(component.isLoading()).toBe(false); - }); - }); - - describe('openFile', () => { - it('should open preview URL in new tab', () => { - fixture.detectChanges(); - component.previewUrl.set('https://s3.amazonaws.com/bucket/file.pdf'); - vi.spyOn(window, 'open'); - - component.openFile(); - - expect(window.open).toHaveBeenCalledWith('https://s3.amazonaws.com/bucket/file.pdf', '_blank'); - }); - - it('should not open if previewUrl is null', () => { - fixture.detectChanges(); - component.previewUrl.set(null); - vi.spyOn(window, 'open'); - - component.openFile(); - - expect(window.open).not.toHaveBeenCalled(); - }); - }); - - describe('isImage computed signal', () => { - const imageTestCases = [ - { value: 'photo.jpg', expected: true }, - { value: 'photo.JPG', expected: true }, - { value: 'photo.jpeg', expected: true }, - { value: 'photo.png', expected: true }, - { value: 'photo.gif', expected: true }, - { value: 'photo.webp', expected: true }, - { value: 'photo.svg', expected: true }, - { value: 'photo.bmp', expected: true }, - { value: 'document.pdf', expected: false }, - { value: 'document.doc', expected: false }, - { value: 'data.csv', expected: false }, - { value: 'archive.zip', expected: false }, - { value: 'uploads/folder/photo.png', expected: true }, - { value: 'file-without-extension', expected: false }, - ]; - - imageTestCases.forEach(({ value, expected }) => { - it(`should return ${expected} for "${value}"`, () => { - fixture.componentRef.setInput('value', value); - fixture.detectChanges(); - - expect(component.isImage()).toBe(expected); - }); - }); - }); - - describe('_loadPreview', () => { - it('should set previewUrl on successful load', async () => { - fixture.componentRef.setInput('value', 'uploads/photo.jpg'); - fakeS3Service.getFileUrl.mockResolvedValue(mockFileUrlResponse); - - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.previewUrl()).toBe(mockFileUrlResponse.url); - expect(component.isLoading()).toBe(false); - }); - - it('should not load preview if value is empty', () => { - fixture.componentRef.setInput('value', ''); - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - - it('should not load preview if connectionId is missing', async () => { - (component as any).connectionId = ''; - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - await (component as any)._loadPreview(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - - it('should not load preview if tableName is missing', async () => { - fixture.detectChanges(); - (component as any).tableName = ''; - fakeS3Service.getFileUrl.mockClear(); - - await (component as any)._loadPreview(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - - it('should not load preview if rowPrimaryKey is missing', () => { - fixture.componentRef.setInput('rowPrimaryKey', null); - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - fixture.detectChanges(); - - expect(fakeS3Service.getFileUrl).not.toHaveBeenCalled(); - }); - - it('should set isLoading to false when getFileUrl returns null', async () => { - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - fakeS3Service.getFileUrl.mockResolvedValue(null); - - fixture.detectChanges(); - await fixture.whenStable(); - - expect(component.isLoading()).toBe(false); - }); - }); - - describe('params computed signal', () => { - it('should handle undefined widgetStructure gracefully', () => { - fixture.componentRef.setInput('widgetStructure', undefined); - fixture.detectChanges(); - - expect(component.params()).toBeNull(); - }); - - it('should handle null widget_params gracefully', () => { - fixture.componentRef.setInput('widgetStructure', { - ...mockWidgetStructure, - widget_params: null as any, - }); - fixture.detectChanges(); - - expect(component.params()).toBeNull(); - }); - - it('should handle invalid JSON string gracefully', () => { - fixture.componentRef.setInput('widgetStructure', { - ...mockWidgetStructure, - widget_params: 'invalid json' as any, - }); - fixture.detectChanges(); - - expect(component.params()).toBeNull(); - }); - }); - - describe('template integration', () => { - beforeEach(() => { - fixture.detectChanges(); - }); - - it('should display label in form field', () => { - const label = fixture.nativeElement.querySelector('mat-label'); - expect(label.textContent).toContain('Document'); - }); - - it('should show upload button', () => { - const uploadButton = fixture.nativeElement.querySelector('button'); - expect(uploadButton.textContent).toContain('Upload'); - }); - - it('should disable upload button when disabled', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - - const uploadButton = fixture.nativeElement.querySelector('button'); - expect(uploadButton.disabled).toBe(true); - }); - - it('should disable upload button when readonly', () => { - fixture.componentRef.setInput('readonly', true); - fixture.detectChanges(); - - const uploadButton = fixture.nativeElement.querySelector('button'); - expect(uploadButton.disabled).toBe(true); - }); - - it('should disable upload button when loading', () => { - component.isLoading.set(true); - fixture.detectChanges(); - - const uploadButton = fixture.nativeElement.querySelector('button'); - expect(uploadButton.disabled).toBe(true); - }); - - it('should show open button when previewUrl exists', () => { - component.previewUrl.set('https://example.com/file.pdf'); - fixture.detectChanges(); - - const buttons = fixture.nativeElement.querySelectorAll('button'); - const openButton = Array.from(buttons).find((b: any) => b.textContent.includes('Open')); - expect(openButton).toBeTruthy(); - }); - - it('should not show open button when previewUrl is null', () => { - component.previewUrl.set(null); - fixture.detectChanges(); - - const buttons = fixture.nativeElement.querySelectorAll('button'); - const openButton = Array.from(buttons).find((b: any) => b.textContent.includes('Open')); - expect(openButton).toBeFalsy(); - }); - - it('should show spinner when loading', () => { - fixture.componentRef.setInput('value', 'uploads/file.pdf'); - component.isLoading.set(true); - fixture.detectChanges(); - - const spinner = fixture.nativeElement.querySelector('mat-spinner'); - expect(spinner).toBeTruthy(); - }); - - it('should show image preview for image files', () => { - fixture.componentRef.setInput('value', 'uploads/photo.jpg'); - component.previewUrl.set('https://example.com/photo.jpg'); - component.isLoading.set(false); - fixture.detectChanges(); - - const img = fixture.nativeElement.querySelector('.s3-widget__thumbnail'); - expect(img).toBeTruthy(); - expect(img.src).toBe('https://example.com/photo.jpg'); - }); - - it('should show file icon for non-image files', () => { - fixture.componentRef.setInput('value', 'uploads/document.pdf'); - component.previewUrl.set('https://example.com/document.pdf'); - component.isLoading.set(false); - fixture.detectChanges(); - - const fileIcon = fixture.nativeElement.querySelector('.s3-widget__file-icon'); - expect(fileIcon).toBeTruthy(); - }); - - it('should show truncated filename for long filenames', () => { - fixture.componentRef.setInput('value', 'uploads/very-long-filename-that-should-be-truncated.pdf'); - component.previewUrl.set('https://example.com/file.pdf'); - component.isLoading.set(false); - fixture.detectChanges(); - - const filename = fixture.nativeElement.querySelector('.s3-widget__filename'); - expect(filename).toBeTruthy(); - }); - }); - - describe('onValueChange', () => { - it('should update internalValue and emit change', () => { - fixture.detectChanges(); - vi.spyOn(component.onFieldChange, 'emit'); - - component.onValueChange('new-value.pdf'); - - expect(component.internalValue()).toBe('new-value.pdf'); - expect(component.onFieldChange.emit).toHaveBeenCalledWith('new-value.pdf'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.stories.ts deleted file mode 100644 index 0efe02b82..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { S3EditComponent } from './s3.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/S3', - component: S3EditComponent, - args: { - key: 'field_name', - label: 'S3 Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts deleted file mode 100644 index 8deba668c..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/s3/s3.component.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, input, OnInit, output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { normalizeFieldName } from 'src/app/lib/normalize'; -import { TableField, TableForeignKey, WidgetStructure } from 'src/app/models/table'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; - -type BucketProvider = 'aws' | 'digitalocean' | 'backblaze' | 'wasabi' | 'cloudflare-r2'; - -interface BucketWidgetParams { - provider?: BucketProvider; - bucket: string; - prefix?: string; - region?: string; - account_id?: string; - access_key_id: string; - access_key: string; - type?: 'file' | 'image'; -} - -@Component({ - selector: 'app-edit-s3', - templateUrl: './s3.component.html', - styleUrl: './s3.component.css', - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatProgressSpinnerModule, - ], -}) -export class S3EditComponent implements OnInit { - // Signal-based inputs - readonly key = input(); - readonly label = input(); - readonly required = input(false); - readonly readonly = input(false); - readonly structure = input(); - readonly disabled = input(false); - readonly widgetStructure = input(); - readonly relations = input(); - readonly value = input(); - readonly rowPrimaryKey = input>(); - - // Signal-based output - readonly onFieldChange = output(); - - // Internal state as signals - readonly previewUrl = signal(null); - readonly isLoading = signal(false); - readonly internalValue = signal(''); - - // Computed signals - readonly normalizedLabel = computed(() => normalizeFieldName(this.label() || '')); - - readonly params = computed(() => { - const ws = this.widgetStructure(); - if (!ws?.widget_params) return null; - try { - return typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - } catch { - return null; - } - }); - - readonly displayFilename = computed(() => { - const val = this.internalValue() || this.value(); - if (!val) return ''; - const name = val.split('/').pop() || val; - return name.length > 40 ? '...' + name.slice(-37) : name; - }); - - readonly isImage = computed(() => { - const p = this.params(); - const val = this.internalValue() || this.value(); - if (p?.type === 'image') return true; - if (!val) return false; - const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']; - return imageExtensions.some((ext) => val.toLowerCase().endsWith(ext)); - }); - - readonly fileAccept = computed(() => { - return this.params()?.type === 'image' ? 'image/*' : '*/*'; - }); - - // Services - private readonly s3Service = inject(S3Service); - private readonly connectionsService = inject(ConnectionsService); - private readonly tablesService = inject(TablesService); - - private connectionId: string; - private tableName: string; - - constructor() { - // Effect to load preview when value changes - effect(() => { - const val = this.value(); - const rowPk = this.rowPrimaryKey(); - if (val && rowPk && !this.previewUrl() && !this.isLoading()) { - this._loadPreview(); - } - }); - } - - ngOnInit(): void { - this.connectionId = this.connectionsService.currentConnectionID; - this.tableName = this.tablesService.currentTableName; - - // Initialize internal value from input - const initialValue = this.value(); - if (initialValue) { - this.internalValue.set(initialValue); - this._loadPreview(); - } - } - - async onFileSelected(event: Event): Promise { - const input = event.target as HTMLInputElement; - if (!input.files?.length) return; - - const file = input.files[0]; - const fieldName = this.widgetStructure()?.field_name; - if (!fieldName) return; - - this.isLoading.set(true); - - const result = await this.s3Service.uploadFile(this.connectionId, this.tableName, fieldName, file); - - if (result) { - this.internalValue.set(result.key); - this.previewUrl.set(result.previewUrl); - this.onFieldChange.emit(result.key); - } - - this.isLoading.set(false); - } - - openFile(): void { - const url = this.previewUrl(); - if (url) { - window.open(url, '_blank'); - } - } - - onValueChange(newValue: string): void { - this.internalValue.set(newValue); - this.onFieldChange.emit(newValue); - } - - private async _loadPreview(): Promise { - const val = this.value() || this.internalValue(); - const rowPk = this.rowPrimaryKey(); - const fieldName = this.widgetStructure()?.field_name; - - if (!val || !this.connectionId || !this.tableName || !rowPk || !fieldName) return; - - this.isLoading.set(true); - - const response = await this.s3Service.getFileUrl(this.connectionId, this.tableName, fieldName, rowPk); - - if (response) { - this.previewUrl.set(response.url); - } - - this.isLoading.set(false); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.css b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.css deleted file mode 100644 index 828b119a7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.select-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html deleted file mode 100644 index fc9019433..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - {{normalizedLabel()}} - - @for (option of options; track option.value) { - - {{option.label}} - - } - - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts deleted file mode 100644 index 3ef4a1b34..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { SelectEditComponent } from './select.component'; - -describe('SelectEditComponent', () => { - let component: SelectEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SelectEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SelectEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have empty options by default', () => { - expect(component.options).toEqual([]); - }); - - it('should load options from widgetStructure widget_params', () => { - const options = [ - { value: 'opt1', label: 'Option 1' }, - { value: 'opt2', label: 'Option 2' }, - ]; - fixture.componentRef.setInput('widgetStructure', { widget_params: { options } } as any); - component.ngOnInit(); - expect(component.options).toEqual(options); - }); - - it('should prepend null option when widgetStructure allow_null is true', () => { - const options = [{ value: 'opt1', label: 'Option 1' }]; - fixture.componentRef.setInput('widgetStructure', { widget_params: { options, allow_null: true } } as any); - component.ngOnInit(); - expect(component.options[0]).toEqual({ value: null, label: '' }); - expect(component.options.length).toBe(2); - }); - - it('should not prepend null option when widgetStructure allow_null is false', () => { - const options = [{ value: 'opt1', label: 'Option 1' }]; - fixture.componentRef.setInput('widgetStructure', { widget_params: { options, allow_null: false } } as any); - component.ngOnInit(); - expect(component.options.length).toBe(1); - expect(component.options[0].value).toBe('opt1'); - }); - - it('should load options from structure data_type_params when no widgetStructure', () => { - fixture.componentRef.setInput('structure', { - data_type_params: ['active', 'inactive', 'pending'], - allow_null: false, - } as any); - component.ngOnInit(); - expect(component.options).toEqual([ - { value: 'active', label: 'active' }, - { value: 'inactive', label: 'inactive' }, - { value: 'pending', label: 'pending' }, - ]); - }); - - it('should prepend null option from structure when allow_null is true', () => { - fixture.componentRef.setInput('structure', { - data_type_params: ['active', 'inactive'], - allow_null: true, - } as any); - component.ngOnInit(); - expect(component.options[0]).toEqual({ value: null, label: '' }); - expect(component.options.length).toBe(3); - }); - - it('should return 0 from originalOrder', () => { - expect(component.originalOrder()).toBe(0); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.stories.ts deleted file mode 100644 index ed5d2a871..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.stories.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { SelectEditComponent } from './select.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'enum', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, - data_type_params: ['option1', 'option2', 'option3'], -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Select', - component: SelectEditComponent, - args: { - key: 'field_name', - label: 'Select Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'option1', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts deleted file mode 100644 index 19c2e5d78..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/select/select.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-select', - imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], - templateUrl: './select.component.html', - styleUrls: ['./select.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class SelectEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public options: { value: string | null; label: string }[] = []; - - originalOrder = () => { - return 0; - }; - - ngOnInit(): void { - super.ngOnInit(); - const ws = this.widgetStructure(); - const struct = this.structure(); - - if (ws) { - this.options = ws.widget_params.options; - if (ws.widget_params.allow_null) { - this.options = [{ value: null, label: '' }, ...this.options]; - } - } else if (struct) { - this.options = struct.data_type_params.map((option) => { - return { value: option, label: option }; - }); - if (struct.allow_null) { - this.options = [{ value: null, label: '' }, ...this.options]; - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.css b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html deleted file mode 100644 index 83a49985b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- {{ normalizedLabel() }} - @if (value() === null) { - NULL - } - @if (value()) { - {{ value() }} - } -
diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.spec.ts deleted file mode 100644 index 029442c47..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { StaticTextEditComponent } from './static-text.component'; - -describe('StaticTextEditComponent', () => { - let component: StaticTextEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StaticTextEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(StaticTextEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.stories.ts deleted file mode 100644 index cc31ab68e..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { StaticTextEditComponent } from './static-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/StaticText', - component: StaticTextEditComponent, - args: { - key: 'field_name', - label: 'Static Text Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'This is static text content', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts deleted file mode 100644 index c4402c407..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/static-text/static-text.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model } from '@angular/core'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-static-text', - imports: [CommonModule], - templateUrl: './static-text.component.html', - styleUrls: ['./static-text.component.css'], -}) -export class StaticTextEditComponent extends BaseEditFieldComponent { - readonly value = model(); -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.css b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.css deleted file mode 100644 index 51d757486..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.text-form-field { - width: 100%; -} - -.counter { - margin-right: 16px; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html deleted file mode 100644 index 89c0dc055..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - {{normalizedLabel()}} - - @if (maxLength && maxLength > 0 && value() && 100 > (maxLength - value().length)) { -
{{value().length}} / {{maxLength}}
- } - @if (textField.errors?.['required']) { - This field is required. - } - @if (textField.errors?.['maxlength']) { - Maximum length is {{maxLength}} characters. - } - @if (textField.errors?.['invalidPattern'] || textField.errors?.[('invalid' + validateType)]) { - {{getValidationErrorMessage()}} - } -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts deleted file mode 100644 index 89483fdc8..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TextEditComponent } from './text.component'; - -describe('TextEditComponent', () => { - let component: TextEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TextEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set maxLength from structure character_maximum_length', () => { - fixture.componentRef.setInput('structure', { character_maximum_length: 255 } as any); - component.ngOnInit(); - expect(component.maxLength).toBe(255); - }); - - it('should keep maxLength null when structure has no character_maximum_length', () => { - fixture.componentRef.setInput('structure', {} as any); - component.ngOnInit(); - expect(component.maxLength).toBeNull(); - }); - - it('should parse validateType from widget params object', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isEmail' } } as any); - component.ngOnInit(); - expect(component.validateType).toBe('isEmail'); - }); - - it('should parse validateType from widget params string', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ validate: 'isURL' }) } as any); - component.ngOnInit(); - expect(component.validateType).toBe('isURL'); - }); - - it('should parse regexPattern from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { validate: 'regex', regex: '^[a-z]+$' }, - } as any); - component.ngOnInit(); - expect(component.regexPattern).toBe('^[a-z]+$'); - }); - - it('should return empty string for getValidationErrorMessage when no validateType', () => { - component.validateType = null; - expect(component.getValidationErrorMessage()).toBe(''); - }); - - it('should return regex message for regex validateType', () => { - component.validateType = 'regex'; - expect(component.getValidationErrorMessage()).toBe("Value doesn't match the required pattern"); - }); - - it('should return correct message for isEmail validateType', () => { - component.validateType = 'isEmail'; - expect(component.getValidationErrorMessage()).toBe('Invalid email address'); - }); - - it('should return correct message for isURL validateType', () => { - component.validateType = 'isURL'; - expect(component.getValidationErrorMessage()).toBe('Invalid URL'); - }); - - it('should return correct message for isJSON validateType', () => { - component.validateType = 'isJSON'; - expect(component.getValidationErrorMessage()).toBe('Invalid JSON'); - }); - - it('should return fallback message for unknown validateType', () => { - component.validateType = 'customValidator'; - expect(component.getValidationErrorMessage()).toBe('Invalid customValidator'); - }); - - it('should default forceSendEmptyString to false when widget_params omits the key', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: null, regex: null } } as any); - component.ngOnInit(); - expect(component.forceSendEmptyString).toBe(false); - }); - - it('should emit null on empty input when column is nullable and force_send_empty_string is not set', () => { - fixture.componentRef.setInput('structure', { allow_null: true } as any); - component.ngOnInit(); - const emitted: any[] = []; - component.onFieldChange.subscribe((v) => emitted.push(v)); - component.handleValueChange(''); - expect(emitted).toEqual([null]); - }); - - it('should emit empty string on empty input when column is not nullable', () => { - fixture.componentRef.setInput('structure', { allow_null: false } as any); - component.ngOnInit(); - const emitted: any[] = []; - component.onFieldChange.subscribe((v) => emitted.push(v)); - component.handleValueChange(''); - expect(emitted).toEqual(['']); - }); - - it('should emit empty string on empty input when force_send_empty_string is true even on nullable column', () => { - fixture.componentRef.setInput('structure', { allow_null: true } as any); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { force_send_empty_string: true }, - } as any); - component.ngOnInit(); - const emitted: any[] = []; - component.onFieldChange.subscribe((v) => emitted.push(v)); - component.handleValueChange(''); - expect(emitted).toEqual(['']); - }); - - it('should emit non-empty value unchanged regardless of nullability', () => { - fixture.componentRef.setInput('structure', { allow_null: true } as any); - component.ngOnInit(); - const emitted: any[] = []; - component.onFieldChange.subscribe((v) => emitted.push(v)); - component.handleValueChange('hello'); - expect(emitted).toEqual(['hello']); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.stories.ts deleted file mode 100644 index 566d22a1d..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TextEditComponent } from './text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Text', - component: TextEditComponent, - args: { - key: 'field_name', - label: 'Text Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'Sample text value', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts deleted file mode 100644 index 6063f88d1..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/text/text.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { TextValidatorDirective } from 'src/app/directives/text-validator.directive'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-text', - templateUrl: './text.component.html', - styleUrls: ['./text.component.css'], - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule, TextValidatorDirective], -}) -export class TextEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - - static type = 'text'; - - maxLength: number | null = null; - validateType: string | null = null; - regexPattern: string | null = null; - forceSendEmptyString: boolean = false; - - override ngOnInit(): void { - super.ngOnInit(); - - const struct = this.structure(); - if (struct?.character_maximum_length) { - this.maxLength = struct.character_maximum_length; - } - - const ws = this.widgetStructure(); - if (ws?.widget_params) { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - - this.validateType = params.validate || null; - this.regexPattern = params.regex || null; - this.forceSendEmptyString = !!params.force_send_empty_string; - } - } - - handleValueChange(v: string | null): void { - if (v === '' && !this.forceSendEmptyString && this.structure()?.allow_null === true) { - this.onFieldChange.emit(null); - return; - } - this.onFieldChange.emit(v); - } - - getValidationErrorMessage(): string { - if (!this.validateType) { - return ''; - } - - if (this.validateType === 'regex') { - return "Value doesn't match the required pattern"; - } - - const messages = { - isEmail: 'Invalid email address', - isURL: 'Invalid URL', - isIP: 'Invalid IP address', - isUUID: 'Invalid UUID', - isJSON: 'Invalid JSON', - isCreditCard: 'Invalid credit card number', - isISBN: 'Invalid ISBN', - isAlpha: 'Should contain only letters', - isNumeric: 'Should contain only numbers', - isAlphanumeric: 'Should contain only letters and numbers', - isHexColor: 'Invalid hex color', - isBase64: 'Invalid Base64 string', - isMobilePhone: 'Invalid mobile phone number', - isMACAddress: 'Invalid MAC address', - isPostalCode: 'Invalid postal code', - isCurrency: 'Invalid currency format', - }; - - return messages[this.validateType] || `Invalid ${this.validateType}`; - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.css b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.css deleted file mode 100644 index 8b6934468..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.css +++ /dev/null @@ -1,13 +0,0 @@ -.feild-set-6 { - display: grid; - grid-template-columns: repeat(3, 1fr); - grid-column-gap: 16px; -} - -::ng-deep .interval-form-field .mat-mdc-form-field-infix { - width: auto !important; -} - -.label { - grid-column: 1 / span 3; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html deleted file mode 100644 index 3d61ace1d..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
- {{normalizedLabel()}} @if (required()) { * } - - years - - - - months - - - - days - - - - hours - - - - minutes - - - - seconds - - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts deleted file mode 100644 index c07a0fb26..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TimeIntervalEditComponent } from './time-interval.component'; - -describe('TimeIntervalEditComponent', () => { - let component: TimeIntervalEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeIntervalEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeIntervalEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should have default empty interval values', () => { - expect(component.interval.years).toBe(''); - expect(component.interval.months).toBe(''); - expect(component.interval.days).toBe(''); - expect(component.interval.hours).toBe(''); - expect(component.interval.minutes).toBe(''); - expect(component.interval.seconds).toBe(''); - expect(component.interval.milliseconds).toBe(''); - }); - - it('should assign value to interval on init when value exists', () => { - const intervalValue = { - years: '1', - months: '2', - days: '3', - hours: '4', - minutes: '30', - seconds: '0', - milliseconds: '0', - }; - fixture.componentRef.setInput('value', intervalValue); - component.ngOnInit(); - expect(component.interval).toBe(intervalValue); - }); - - it('should keep default interval when value is falsy on init', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.interval.years).toBe(''); - }); - - it('should emit postgres interval string on input change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.interval = { - years: '1', - months: '0', - days: '5', - hours: '0', - minutes: '0', - seconds: '0', - milliseconds: '0', - }; - component.onInputChange(); - expect(component.onFieldChange.emit).toHaveBeenCalled(); - const emittedValue = (component.onFieldChange.emit as any).mock.calls[0][0]; - expect(typeof emittedValue).toBe('string'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.stories.ts deleted file mode 100644 index 8a44390a5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeIntervalEditComponent } from './time-interval.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'interval', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/TimeInterval', - component: TimeIntervalEditComponent, - args: { - key: 'field_name', - label: 'Time Interval Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '01:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts deleted file mode 100644 index 0a87bfc33..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time-interval/time-interval.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { CommonModule } from '@angular/common'; - -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import * as pgInterval from 'postgres-interval'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-time-interval', - imports: [CommonModule, MatFormFieldModule, MatInputModule, FormsModule], - templateUrl: './time-interval.component.html', - styleUrls: ['./time-interval.component.css'], -}) -export class TimeIntervalEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public interval = { - years: '', - months: '', - days: '', - hours: '', - minutes: '', - seconds: '', - milliseconds: '', - }; - - ngOnInit(): void { - super.ngOnInit(); - if (this.value()) this.interval = this.value(); - } - - onInputChange() { - // @ts-expect-error - const currentInterval = pgInterval.prototype.toPostgres.call(this.interval); - this.onFieldChange.emit(currentInterval); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.css b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.css deleted file mode 100644 index 935f11420..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.time-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html deleted file mode 100644 index 44b2b29f5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.html +++ /dev/null @@ -1,7 +0,0 @@ - - {{normalizedLabel()}} - - diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.spec.ts deleted file mode 100644 index 6a4c0cf6a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { TimeEditComponent } from './time.component'; - -describe('TimeEditComponent', () => { - let component: TimeEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.stories.ts deleted file mode 100644 index dd9598aa0..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeEditComponent } from './time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'time', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Time', - component: TimeEditComponent, - args: { - key: 'field_name', - label: 'Time Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '10:30:00', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts deleted file mode 100644 index 6a70d213a..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/time/time.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-time', - templateUrl: './time.component.html', - styleUrls: ['./time.component.css'], - imports: [MatFormFieldModule, MatInputModule, FormsModule], -}) -export class TimeEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - static type = 'datetime'; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css deleted file mode 100644 index 388e7c15e..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.timezone-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html deleted file mode 100644 index 839c684fa..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.html +++ /dev/null @@ -1,17 +0,0 @@ - - {{normalizedLabel()}} - - @for (timezone of timezones; track timezone.value) { - - {{timezone.label}} - - } - - \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts deleted file mode 100644 index 9d40c99d1..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { TimezoneEditComponent } from './timezone.component'; - -describe('TimezoneEditComponent', () => { - let component: TimezoneEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimezoneEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimezoneEditComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should populate timezones using Intl API', () => { - expect(component.timezones.length).toBeGreaterThan(0); - }); - - it('should include timezone offset in labels', () => { - const timezone = component.timezones.find((tz) => tz.value === 'America/New_York'); - expect(timezone).toBeDefined(); - expect(timezone.label).toContain('UTC'); - }); - - it('should emit value on change', () => { - vi.spyOn(component.onFieldChange, 'emit'); - const testValue = 'America/New_York'; - fixture.componentRef.setInput('value', testValue); - component.onFieldChange.emit(testValue); - expect(component.onFieldChange.emit).toHaveBeenCalledWith(testValue); - }); - - it('should add null option when allow_null is true', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { allow_null: true }, - } as any); - component.ngOnInit(); - const nullOption = component.timezones.find((tz) => tz.value === null); - expect(nullOption).toBeDefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.stories.ts deleted file mode 100644 index de82e8bfb..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimezoneEditComponent } from './timezone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/Timezone', - component: TimezoneEditComponent, - args: { - key: 'field_name', - label: 'Timezone Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'UTC', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts deleted file mode 100644 index ec3e63ad5..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/timezone/timezone.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, model } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; - -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-timezone', - imports: [CommonModule, FormsModule, MatFormFieldModule, MatSelectModule], - templateUrl: './timezone.component.html', - styleUrls: ['./timezone.component.css'], - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class TimezoneEditComponent extends BaseEditFieldComponent { - readonly value = model(); - - public timezones: { value: string; label: string }[] = []; - - originalOrder = () => { - return 0; - }; - - ngOnInit(): void { - super.ngOnInit(); - this.initializeTimezones(); - } - - private initializeTimezones(): void { - // Get all available timezone identifiers from Intl API - const timezoneList = Intl.supportedValuesOf('timeZone'); - - // Map timezones to format with offset and readable label - this.timezones = timezoneList.map((tz) => { - const offset = this.getTimezoneOffset(tz); - return { - value: tz, - label: `${tz} (UTC${offset})`, - }; - }); - - // Sort by timezone name - this.timezones.sort((a, b) => a.value.localeCompare(b.value)); - - // Check widget params for allow_null option - const ws = this.widgetStructure(); - if (ws?.widget_params?.allow_null) { - this.timezones = [{ value: null, label: '' }, ...this.timezones]; - } else if (this.structure()?.allow_null) { - this.timezones = [{ value: null, label: '' }, ...this.timezones]; - } - } - - private getTimezoneOffset(timezone: string): string { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset', - }); - - const parts = formatter.formatToParts(now); - const offsetPart = parts.find((part) => part.type === 'timeZoneName'); - - if (offsetPart?.value.includes('GMT')) { - // Extract offset from "GMT+XX:XX" format - const offset = offsetPart.value.replace('GMT', ''); - return offset === '' ? '+00:00' : offset; - } - - // Fallback: calculate offset manually - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); - const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; - const hours = Math.floor(Math.abs(offsetMinutes) / 60); - const minutes = Math.abs(offsetMinutes) % 60; - const sign = offsetMinutes >= 0 ? '+' : '-'; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - } catch (_error) { - return ''; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.css b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.css deleted file mode 100644 index 1502b7426..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.url-box { - margin-bottom: 12px; - width: 100%; -} - -/* Handle long URL prefixes with overflow */ -::ng-deep .mat-mdc-form-field-text-prefix { - max-width: 30%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: inline-block; - vertical-align: middle; -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html deleted file mode 100644 index 4ee0ac040..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - {{normalizedLabel()}} - @if (prefix) { - {{prefix}} - } - - @if (image.errors?.isInvalidURL) { - URL is invalid. - } - \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts deleted file mode 100644 index 9edd8c4a4..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { UrlEditComponent } from './url.component'; - -describe('UrlComponent', () => { - let component: UrlEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, UrlEditComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(UrlEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should have empty prefix by default', () => { - expect(component.prefix).toBe(''); - }); - - it('should parse prefix from widget params object', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://api.example.com/' } } as any); - component.ngOnInit(); - expect(component.prefix).toBe('https://api.example.com/'); - }); - - it('should parse prefix from widget params string', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: JSON.stringify({ prefix: 'https://test.com/' }) } as any); - component.ngOnInit(); - expect(component.prefix).toBe('https://test.com/'); - }); - - it('should keep empty prefix when widget params have no prefix', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); - component.ngOnInit(); - expect(component.prefix).toBe(''); - }); - - it('should update prefix on ngOnChanges', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://updated.com/' } } as any); - component.ngOnChanges(); - expect(component.prefix).toBe('https://updated.com/'); - }); - - it('should handle invalid JSON in widget params gracefully', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: 'invalid-json' } as any); - component.ngOnInit(); - expect(component.prefix).toBe(''); - }); - - it('should not change prefix when widgetStructure is undefined', () => { - fixture.componentRef.setInput('widgetStructure', undefined); - component.ngOnInit(); - expect(component.prefix).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.stories.ts deleted file mode 100644 index 8ed9d39e7..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UrlEditComponent } from './url.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/URL', - component: UrlEditComponent, - args: { - key: 'field_name', - label: 'URL Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: 'https://example.com', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts deleted file mode 100644 index 4d2428727..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/url/url.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { UrlValidatorDirective } from 'src/app/directives/url-validator.directive'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-url', - imports: [CommonModule, FormsModule, MatFormFieldModule, MatInputModule, UrlValidatorDirective], - templateUrl: './url.component.html', - styleUrl: './url.component.css', -}) -export class UrlEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - public prefix: string = ''; - - ngOnInit(): void { - super.ngOnInit(); - this._parseWidgetParams(); - } - - ngOnChanges(): void { - this._parseWidgetParams(); - } - - private _parseWidgetParams(): void { - const ws = this.widgetStructure(); - if (ws?.widget_params) { - try { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - - if (params.prefix !== undefined) { - this.prefix = params.prefix || ''; - } - } catch (e) { - console.error('Error parsing URL widget params:', e); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.css b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.css deleted file mode 100644 index f5208688c..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.css +++ /dev/null @@ -1,54 +0,0 @@ -.uuid-field { - width: 100%; -} - -.uuid-input { - font-family: "Roboto Mono", monospace; - font-size: 14px; -} - -.regenerate-button { - cursor: pointer; -} - -.regenerate-button:hover { - color: var(--mat-primary); -} - -.uuid-config-info { - display: flex; - align-items: center; - gap: 8px; - margin-top: -16px; - margin-bottom: 16px; - padding: 8px; - background-color: var(--mat-grey-100); - border-radius: 4px; - font-size: 12px; -} - -.info-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: var(--mat-grey-600); -} - -.info-text { - color: var(--mat-grey-700); -} - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .uuid-config-info { - background-color: var(--mat-grey-800); - } - - .info-icon { - color: var(--mat-grey-400); - } - - .info-text { - color: var(--mat-grey-300); - } -} diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html deleted file mode 100644 index 4b28ea60b..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.html +++ /dev/null @@ -1,46 +0,0 @@ - - {{ normalizedLabel() }} - - - @if (!readonly() && !disabled()) { - - } - - @if (value() && validateUuid(value())) { - - UUID {{ getUuidVersion(value()) ? 'v' + getUuidVersion(value()) : 'version unknown' }} - - } - - @if (value() && !validateUuid(value())) { - Invalid UUID format - } - - - -@if (!readonly() && !disabled() && (uuidVersion === 'v3' || uuidVersion === 'v5')) { -
- info - - UUID {{ uuidVersion }} uses namespace/name hashing. Configure these in widget parameters. - -
-} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.spec.ts b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.spec.ts deleted file mode 100644 index d4191d55d..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { validate as uuidValidate } from 'uuid'; -import { UuidEditComponent } from './uuid.component'; - -describe('UuidEditComponent', () => { - let component: UuidEditComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UuidEditComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UuidEditComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should be in create mode when value is empty', () => { - fixture.componentRef.setInput('value', ''); - component.ngOnInit(); - expect(component.isCreateMode).toBe(true); - }); - - it('should be in update mode when value is set', () => { - fixture.componentRef.setInput('value', '550e8400-e29b-41d4-a716-446655440000'); - component.ngOnInit(); - expect(component.isCreateMode).toBe(false); - }); - - it('should generate a UUID on create mode', () => { - fixture.componentRef.setInput('value', ''); - component.ngOnInit(); - expect(component.value()).toBeTruthy(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should not overwrite existing value on init', () => { - const existingUuid = '550e8400-e29b-41d4-a716-446655440000'; - fixture.componentRef.setInput('value', existingUuid); - component.ngOnInit(); - expect(component.value()).toBe(existingUuid); - }); - - it('should default to v4 UUID version', () => { - expect(component.uuidVersion).toBe('v4'); - }); - - it('should parse widget params for version', () => { - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { version: 'v1' }, - } as any); - component.ngOnInit(); - expect(component.uuidVersion).toBe('v1'); - }); - - it('should parse widget params for namespace and name', () => { - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - version: 'v5', - namespace: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', - name: 'test', - }, - } as any); - component.ngOnInit(); - expect(component.namespace).toBe('6ba7b811-9dad-11d1-80b4-00c04fd430c8'); - expect(component.name).toBe('test'); - }); - - it('should generate a valid v1 UUID', () => { - component.uuidVersion = 'v1'; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should generate a valid v4 UUID', () => { - component.uuidVersion = 'v4'; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should generate a valid v7 UUID', () => { - component.uuidVersion = 'v7'; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should generate a v3 UUID with name', () => { - component.uuidVersion = 'v3'; - component.name = 'test-name'; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should generate a v5 UUID with name', () => { - component.uuidVersion = 'v5'; - component.name = 'test-name'; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should generate deterministic v5 UUID for same inputs', () => { - component.uuidVersion = 'v5'; - component.name = 'test'; - component.namespace = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; - component.generateUuid(); - const firstUuid = component.value(); - - component.generateUuid(); - const secondUuid = component.value(); - - expect(firstUuid).toBe(secondUuid); - }); - - it('should emit onFieldChange when UUID is generated', () => { - vi.spyOn(component.onFieldChange, 'emit'); - component.generateUuid(); - expect(component.onFieldChange.emit).toHaveBeenCalled(); - }); - - it('should fallback to v4 for unknown version', () => { - component.uuidVersion = 'v99' as any; - component.generateUuid(); - expect(uuidValidate(component.value())).toBe(true); - }); - - it('should validate a valid UUID', () => { - expect(component.validateUuid('550e8400-e29b-41d4-a716-446655440000')).toBe(true); - }); - - it('should invalidate a non-UUID string', () => { - expect(component.validateUuid('not-a-uuid')).toBe(false); - }); - - it('should return UUID version for valid UUID', () => { - const result = component.getUuidVersion('550e8400-e29b-41d4-a716-446655440000'); - expect(result).toBe(4); - }); - - it('should return false for invalid UUID in getUuidVersion', () => { - const result = component.getUuidVersion('invalid'); - expect(result).toBe(false); - }); - - it('should have available versions list', () => { - expect(component.availableVersions.length).toBe(5); - expect(component.availableVersions.map((v) => v.value)).toEqual(['v1', 'v3', 'v4', 'v5', 'v7']); - }); - - it('should have standard namespaces', () => { - expect(component.namespaces.length).toBe(4); - expect(component.namespaces.map((n) => n.label)).toEqual(['DNS', 'URL', 'OID', 'X500']); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.stories.ts b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.stories.ts deleted file mode 100644 index 8d3910e2c..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UuidEditComponent } from './uuid.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'uuid', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record Edit Fields/UUID', - component: UuidEditComponent, - args: { - key: 'field_name', - label: 'UUID Field', - required: false, - readonly: false, - disabled: false, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - value: '550e8400-e29b-41d4-a716-446655440000', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; -export const Required: Story = { args: { required: true } }; -export const Readonly: Story = { args: { readonly: true } }; -export const Disabled: Story = { args: { disabled: true } }; diff --git a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.ts b/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.ts deleted file mode 100644 index f0b433c7f..000000000 --- a/frontend/src/app/components/ui-components/record-edit-fields/uuid/uuid.component.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, model, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { - validate as uuidValidate, - version as uuidVersion, - v1 as uuidv1, - v3 as uuidv3, - v4 as uuidv4, - v5 as uuidv5, - v7 as uuidv7, -} from 'uuid'; -import { BaseEditFieldComponent } from '../base-row-field/base-row-field.component'; - -@Component({ - selector: 'app-edit-uuid', - templateUrl: './uuid.component.html', - styleUrls: ['./uuid.component.css'], - imports: [ - CommonModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - MatSelectModule, - MatTooltipModule, - FormsModule, - ], -}) -export class UuidEditComponent extends BaseEditFieldComponent implements OnInit { - readonly value = model(); - - static type = 'uuid'; - - public uuidVersion: string = 'v4'; - public isCreateMode: boolean = true; - public namespace: string = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // Default DNS namespace - public name: string = ''; - - // Available UUID versions - public availableVersions = [ - { value: 'v1', label: 'v1 (timestamp + MAC)', description: 'Timestamp and MAC address based' }, - { value: 'v3', label: 'v3 (MD5 hash)', description: 'MD5 hash of namespace and name' }, - { value: 'v4', label: 'v4 (random)', description: 'Random UUID (most common)' }, - { value: 'v5', label: 'v5 (SHA-1 hash)', description: 'SHA-1 hash of namespace and name' }, - { value: 'v7', label: 'v7 (timestamp)', description: 'Timestamp-based sortable UUID' }, - ]; - - // Standard namespaces for v3/v5 - public namespaces = [ - { value: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', label: 'DNS' }, - { value: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', label: 'URL' }, - { value: '6ba7b812-9dad-11d1-80b4-00c04fd430c8', label: 'OID' }, - { value: '6ba7b814-9dad-11d1-80b4-00c04fd430c8', label: 'X500' }, - ]; - - ngOnInit(): void { - super.ngOnInit(); - - // Determine if we're in create or update mode - this.isCreateMode = !this.value() || this.value() === ''; - - // Parse widget parameters - const ws = this.widgetStructure(); - if (ws?.widget_params) { - try { - const params = - typeof ws.widget_params === 'string' - ? JSON.parse(ws.widget_params) - : ws.widget_params; - - if (params.version) { - this.uuidVersion = params.version; - } - if (params.namespace) { - this.namespace = params.namespace; - } - if (params.name) { - this.name = params.name; - } - } catch (e) { - console.error('Error parsing UUID widget params:', e); - } - } - - // Generate UUID on create - if (this.isCreateMode) { - this.generateUuid(); - } - } - - generateUuid(): void { - let uuid = ''; - - try { - switch (this.uuidVersion) { - case 'v1': - uuid = uuidv1(); - break; - case 'v3': - if (this.name) { - uuid = uuidv3(this.name, this.namespace); - } else { - uuid = uuidv3(Date.now().toString(), this.namespace); - } - break; - case 'v4': - uuid = uuidv4(); - break; - case 'v5': - if (this.name) { - uuid = uuidv5(this.name, this.namespace); - } else { - uuid = uuidv5(Date.now().toString(), this.namespace); - } - break; - case 'v7': - uuid = uuidv7(); - break; - default: - uuid = uuidv4(); - } - - this.value.set(uuid); - this.onFieldChange.emit(this.value()); - } catch (error) { - console.error('Error generating UUID:', error); - // Fallback to v4 - this.value.set(uuidv4()); - this.onFieldChange.emit(this.value()); - } - } - - validateUuid(value: string): boolean { - return uuidValidate(value); - } - - getUuidVersion(value: string): number | false { - try { - return uuidVersion(value); - } catch { - return false; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.css b/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.css deleted file mode 100644 index 0694f24bb..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.css +++ /dev/null @@ -1,25 +0,0 @@ -.field-view-value { - word-wrap: anywhere; - word-break: keep-all; - white-space: break-spaces; - max-width: 100%; -} - -@media (prefers-color-scheme: light) { - .field-view-value { - color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .field-view-value { - color: rgba(255, 255, 255, 0.64); - } -} - -.field-view-icon { - font-size: 16px; - height: 16px; - margin-left: 8px; - width: 16px; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.html b/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.html deleted file mode 100644 index 67e1f2808..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.ts b/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.ts deleted file mode 100644 index e594d11ae..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/base-record-view-field/base-record-view-field.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Component, input, output } from '@angular/core'; -import { TableField, WidgetStructure } from 'src/app/models/table'; - -@Component({ - selector: 'app-base-record-view-field', - templateUrl: './base-record-view-field.component.html', - styleUrls: ['./base-record-view-field.component.css'], -}) -export class BaseRecordViewFieldComponent { - readonly key = input(); - readonly value = input(); - readonly structure = input(); - readonly widgetStructure = input(); - readonly rowData = input>(); - readonly primaryKeys = input>(); - - readonly onCopyToClipboard = output(); -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.css b/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.html deleted file mode 100644 index 0e88e629b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- {{value() || '—'}} -
diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.spec.ts deleted file mode 100644 index 427999347..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BinaryDataCaptionRecordViewComponent } from './binary-data-caption.component'; - -describe('BinaryDataCaptionRecordViewComponent', () => { - let component: BinaryDataCaptionRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryDataCaptionRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BinaryDataCaptionRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.stories.ts deleted file mode 100644 index 7d286d36b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BinaryDataCaptionRecordViewComponent } from './binary-data-caption.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'bytea', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/BinaryDataCaption', - component: BinaryDataCaptionRecordViewComponent, - args: { - key: 'field_name', - value: '(binary data)', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '(binary data)' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.ts b/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.ts deleted file mode 100644 index e5faa8310..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary-data-caption/binary-data-caption.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-binary-data-caption-record-view', - templateUrl: './binary-data-caption.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './binary-data-caption.component.css'], - imports: [MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class BinaryDataCaptionRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css deleted file mode 100644 index 48a302fb0..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.binary-view { - display: flex; - align-items: center; - gap: 8px; -} - -.binary-view-value { - font-family: monospace; - font-size: 13px; - word-break: break-all; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html deleted file mode 100644 index 19b931141..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
- {{ displayText() }} - @if (encodedValue()) { - - } -
diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts deleted file mode 100644 index 7c2bc7fe3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BinaryRecordViewComponent } from './binary.component'; - -describe('BinaryRecordViewComponent', () => { - let component: BinaryRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryRecordViewComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(BinaryRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('shows em-dash for empty value', () => { - fixture.detectChanges(); - expect(component.displayText()).toBe('—'); - }); - - it('parses a server string as char-code-per-byte', () => { - fixture.componentRef.setInput('value', 'Hel'); - fixture.detectChanges(); - expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.encodedValue()).toBe('48656c'); - }); - - it('parses a Buffer-JSON value to a byte array', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); - fixture.detectChanges(); - expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.encodedValue()).toBe('48656c'); - }); - - it('truncates long hex with ellipsis', () => { - fixture.componentRef.setInput('value', 'ª'.repeat(50)); - fixture.detectChanges(); - expect(component.encodedValue()).toBe('aa'.repeat(50)); - expect(component.isTruncated()).toBe(true); - expect(component.displayText()).toBe('aa'.repeat(40) + '…'); - }); - - describe('encoding param', () => { - it('defaults to hex when widgetStructure is absent', () => { - fixture.componentRef.setInput('value', 'Hi'); - fixture.detectChanges(); - expect(component.encoding()).toBe('hex'); - expect(component.encodedValue()).toBe('4869'); - }); - - it('renders base64 when encoding=base64', () => { - fixture.componentRef.setInput('value', 'Hello'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - fixture.detectChanges(); - expect(component.encoding()).toBe('base64'); - expect(component.encodedValue()).toBe('SGVsbG8='); - }); - - it('renders ascii with non-printable replacement', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x00, 0x69] }); - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); - fixture.detectChanges(); - expect(component.encoding()).toBe('ascii'); - expect(component.encodedValue()).toBe('H.i'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts deleted file mode 100644 index 22ed34467..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/binary/binary.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, computed } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { bytesToEncoded, isBinaryEncoding, parseBinaryValue } from 'src/app/lib/binary'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -const MAX_DISPLAY_LENGTH = 80; - -@Component({ - selector: 'app-binary-record-view', - templateUrl: './binary.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './binary.component.css'], - imports: [ClipboardModule, MatButtonModule, MatIconModule, MatTooltipModule], -}) -export class BinaryRecordViewComponent extends BaseRecordViewFieldComponent { - public readonly bytes = computed(() => parseBinaryValue(this.value())); - public readonly encoding = computed(() => { - const raw = this.widgetStructure()?.widget_params?.encoding; - return isBinaryEncoding(raw) ? raw : 'hex'; - }); - public readonly encodedValue = computed(() => bytesToEncoded(this.bytes(), this.encoding())); - public readonly copyTooltip = computed(() => `Copy ${this.encoding()}`); - - public readonly displayText = computed(() => { - const encoded = this.encodedValue(); - if (!encoded) return '—'; - return encoded.length > MAX_DISPLAY_LENGTH ? encoded.substring(0, MAX_DISPLAY_LENGTH) + '…' : encoded; - }); - - public readonly isTruncated = computed(() => this.encodedValue().length > MAX_DISPLAY_LENGTH); -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.css b/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.css deleted file mode 100644 index 8aa0f1d9b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.boolean-icon { - transform: scale(0.85); -} - -.boolean-icon-true { - color: var(--color-successPalette-500); -} - -.boolean-icon-false { - color: var(--color-warnPalette-500); -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.html deleted file mode 100644 index 6b24d5b26..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.html +++ /dev/null @@ -1,15 +0,0 @@ -@if (value() === true) { - - check - -} -@if (value() === false) { - - close - -} -@if (value() !== true && value() !== false) { -— -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.spec.ts deleted file mode 100644 index b222ebdab..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BooleanRecordViewComponent } from './boolean.component'; - -describe('BooleanRecordViewComponent', () => { - let component: BooleanRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BooleanRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BooleanRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should return false for invertColors when no widget params', () => { - fixture.componentRef.setInput('widgetStructure', undefined); - expect(component.invertColors).toBe(false); - }); - - it('should return true for invertColors when widget_params.invertColors is true', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { invertColors: true } } as any); - expect(component.invertColors).toBe(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.stories.ts deleted file mode 100644 index 0b36b26f6..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BooleanRecordViewComponent } from './boolean.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'boolean', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Boolean', - component: BooleanRecordViewComponent, - args: { - key: 'field_name', - value: true, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: true }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.ts deleted file mode 100644 index 629cef471..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/boolean/boolean.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-boolean-record-view', - templateUrl: './boolean.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './boolean.component.css'], - imports: [MatIconModule, CommonModule], -}) -export class BooleanRecordViewComponent extends BaseRecordViewFieldComponent { - get invertColors(): boolean { - return this.widgetStructure()?.widget_params?.invertColors === true; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.css b/frontend/src/app/components/ui-components/record-view-fields/code/code.component.css deleted file mode 100644 index 8f1be46a3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.css +++ /dev/null @@ -1,20 +0,0 @@ -:host { - width: 100%; -} - -.code-editor-box { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 0; - margin-top: 4px; - margin-bottom: 20px; - /* overflow-y: auto; - resize: vertical; */ - overflow: auto; - resize: both; -} - -.code-editor-box ::ng-deep .ngs-code-editor { - width: 100%; - height: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.html b/frontend/src/app/components/ui-components/record-view-fields/code/code.component.html deleted file mode 100644 index bc49d5236..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- - -
- diff --git a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/code/code.component.spec.ts deleted file mode 100644 index 7c1df4898..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CodeRecordViewComponent } from './code.component'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; - -describe('CodeRecordViewComponent', () => { - let component: CodeRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CodeRecordViewComponent], - providers: [ - { provide: UiSettingsService, useValue: { isDarkMode: false } }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(CodeRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set code model on init', () => { - fixture.componentRef.setInput('value', 'console.log()'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { language: 'javascript' }, - field_name: 'code', - }); - fixture.componentRef.setInput('key', 'test'); - component.ngOnInit(); - expect(component.codeModel).toEqual({ - language: 'javascript', - uri: 'test.json', - value: 'console.log()', - }); - }); - - it('should use light theme when not dark mode', () => { - fixture.componentRef.setInput('value', 'code'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { language: 'javascript' }, - field_name: 'code', - }); - fixture.componentRef.setInput('key', 'test'); - component.ngOnInit(); - expect(component.codeEditorTheme).toBe('vs'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/code/code.component.stories.ts deleted file mode 100644 index 549ba0000..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CodeRecordViewComponent } from './code.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Code', - component: CodeRecordViewComponent, - args: { - key: 'field_name', - value: 'console.log("hello")', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'console.log("hello")' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.ts b/frontend/src/app/components/ui-components/record-view-fields/code/code.component.ts deleted file mode 100644 index 740f6a105..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/code/code.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component } from '@angular/core'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-code-record-view', - templateUrl: './code.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './code.component.css'], - imports: [CodeEditorModule], -}) -export class CodeRecordViewComponent extends BaseRecordViewFieldComponent { - public codeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - lineNumbers: 'off', - }; - public codeEditorTheme = 'vs-dark'; - - constructor(private _uiSettings: UiSettingsService) { - super(); - } - - ngOnInit(): void { - this.codeModel = { - language: `${this.widgetStructure().widget_params.language}`, - uri: `${this.key()}.json`, - value: this.value(), - }; - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.css b/frontend/src/app/components/ui-components/record-view-fields/color/color.component.css deleted file mode 100644 index bd573c4fa..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.color-display { - display: flex; - align-items: center; - gap: 8px; -} - -.color-swatch { - width: 20px; - height: 20px; - border-radius: 4px; - /* border: 1px solid #ccc; */ - flex-shrink: 0; -} - -.field-value { - font-family: monospace; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.html b/frontend/src/app/components/ui-components/record-view-fields/color/color.component.html deleted file mode 100644 index 38d77bb57..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.html +++ /dev/null @@ -1,10 +0,0 @@ -
- @if (isValidColor) { -
- } - {{value() || '—'}} -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/color/color.component.spec.ts deleted file mode 100644 index dcb68057f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ColorRecordViewComponent } from './color.component'; - -describe('ColorRecordViewComponent', () => { - let component: ColorRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ColorRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ColorRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should validate hex color', () => { - fixture.componentRef.setInput('value', '#ff0000'); - expect(component.isValidColor).toBe(true); - }); - - it('should return false for invalid color', () => { - fixture.componentRef.setInput('value', 'notacolor'); - expect(component.isValidColor).toBe(false); - }); - - it('should normalize color to hex format', () => { - fixture.componentRef.setInput('value', '#ff0000'); - expect(component.normalizedColorForDisplay).toBe('#ff0000'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/color/color.component.stories.ts deleted file mode 100644 index 77954e997..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ColorRecordViewComponent } from './color.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Color', - component: ColorRecordViewComponent, - args: { - key: 'field_name', - value: '#FF5733', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '#FF5733' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.ts b/frontend/src/app/components/ui-components/record-view-fields/color/color.component.ts deleted file mode 100644 index fa2531fb4..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/color/color.component.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Component } from '@angular/core'; -import colorString from 'color-string'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-color-record-view', - templateUrl: './color.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './color.component.css'], - imports: [], -}) -export class ColorRecordViewComponent extends BaseRecordViewFieldComponent { - get isValidColor(): boolean { - if (!this.value()) return false; - return this.parseColor(this.value()) !== null; - } - - get normalizedColorForDisplay(): string { - const parsed = this.parseColor(this.value()); - if (parsed) { - const [r, g, b] = parsed.value; - return `#${this.toHex(r)}${this.toHex(g)}${this.toHex(b)}`; - } - return '#000000'; - } - - private parseColor(color: string): any { - if (!color) return null; - - // Try parsing with color-string - const parsed = colorString.get(color); - if (parsed) return parsed; - - // Try hex without hash - if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { - return colorString.get('#' + color); - } - - return null; - } - - private toHex(n: number): string { - const hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.css b/frontend/src/app/components/ui-components/record-view-fields/country/country.component.css deleted file mode 100644 index e1c4f4d1a..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.country-flag { - margin-right: 6px; - font-size: 1.2em; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.html b/frontend/src/app/components/ui-components/record-view-fields/country/country.component.html deleted file mode 100644 index 4ec31fad7..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - @if (showFlag && countryFlag) { - {{ countryFlag }} - } - {{ countryName }} - diff --git a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/country/country.component.spec.ts deleted file mode 100644 index 553b08a8a..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CountryRecordViewComponent } from './country.component'; - -describe('CountryRecordViewComponent', () => { - let component: CountryRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CountryRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(CountryRecordViewComponent); - component = fixture.componentInstance; - // Don't call fixture.detectChanges() here - let individual tests set inputs first - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should display country name from code', () => { - fixture.componentRef.setInput('value', 'US'); - component.ngOnInit(); - expect(component.countryName).toContain('United States'); - }); - - it('should display em dash for null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.countryName).toBe('—'); - }); - - it('should respect show_flag widget param', () => { - fixture.componentRef.setInput('value', 'US'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { show_flag: false } } as any); - component.ngOnInit(); - expect(component.showFlag).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/country/country.component.stories.ts deleted file mode 100644 index e976fc4bb..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CountryRecordViewComponent } from './country.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Country', - component: CountryRecordViewComponent, - args: { - key: 'field_name', - value: 'US', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'US' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.ts b/frontend/src/app/components/ui-components/record-view-fields/country/country.component.ts deleted file mode 100644 index bf6ba1654..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/country/country.component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-country-record-view', - templateUrl: './country.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './country.component.css'], - imports: [], -}) -export class CountryRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'country'; - - public countryName: string = ''; - public countryFlag: string = ''; - public showFlag: boolean = true; - - ngOnInit(): void { - this.parseWidgetParams(); - - if (this.value()) { - const country = COUNTRIES.find((c) => c.code === this.value()); - this.countryName = country ? country.name : this.value(); - this.countryFlag = getCountryFlag(this.value()); - } else { - this.countryName = '—'; - this.countryFlag = ''; - } - } - - private parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = - typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - if (params.show_flag !== undefined) { - this.showFlag = params.show_flag; - } - } catch (e) { - console.error('Error parsing country widget params:', e); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.html deleted file mode 100644 index c6ed53b32..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.html +++ /dev/null @@ -1 +0,0 @@ -{{formattedDateTime || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.spec.ts deleted file mode 100644 index 1a65f53f1..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DateTimeRecordViewComponent } from './date-time.component'; - -describe('DateTimeRecordViewComponent', () => { - let component: DateTimeRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateTimeRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateTimeRecordViewComponent); - component = fixture.componentInstance; - // Don't call fixture.detectChanges() here - let individual tests set inputs first - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format valid datetime', () => { - fixture.componentRef.setInput('value', '2023-04-29T14:30:00'); - component.ngOnInit(); - expect(component.formattedDateTime).toBeDefined(); - }); - - it('should handle invalid datetime', () => { - fixture.componentRef.setInput('value', 'invalid'); - component.ngOnInit(); - expect(component.formattedDateTime).toBe('invalid'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.stories.ts deleted file mode 100644 index 3fd9580f2..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateTimeRecordViewComponent } from './date-time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'datetime', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/DateTime', - component: DateTimeRecordViewComponent, - args: { - key: 'field_name', - value: '2024-01-15T10:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '2024-01-15T10:30:00' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.ts deleted file mode 100644 index 1b7a38078..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date-time/date-time.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { format } from 'date-fns'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-date-time-record-view', - templateUrl: './date-time.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './date-time.component.css'], - imports: [], -}) -export class DateTimeRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'datetime'; - - public formattedDateTime: string; - - ngOnInit(): void { - if (this.value()) { - try { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - this.formattedDateTime = format(date, 'P p'); - } else { - this.formattedDateTime = this.value(); - } - } catch (_error) { - this.formattedDateTime = this.value(); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.css b/frontend/src/app/components/ui-components/record-view-fields/date/date.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.html b/frontend/src/app/components/ui-components/record-view-fields/date/date.component.html deleted file mode 100644 index 5edf46f97..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.html +++ /dev/null @@ -1 +0,0 @@ -{{formattedDate || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/date/date.component.spec.ts deleted file mode 100644 index 84ec9af92..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DateRecordViewComponent } from './date.component'; - -describe('DateRecordViewComponent', () => { - let component: DateRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateRecordViewComponent); - component = fixture.componentInstance; - // Don't call fixture.detectChanges() here - let individual tests set inputs first - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format valid date', () => { - fixture.componentRef.setInput('value', '2023-04-29'); - component.ngOnInit(); - expect(component.formattedDate).toBeDefined(); - }); - - it('should handle invalid date', () => { - fixture.componentRef.setInput('value', 'invalid'); - component.ngOnInit(); - expect(component.formattedDate).toBe('invalid'); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedDate).toBeUndefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/date/date.component.stories.ts deleted file mode 100644 index 3b35c1eba..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateRecordViewComponent } from './date.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'date', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Date', - component: DateRecordViewComponent, - args: { - key: 'field_name', - value: '2024-01-15', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '2024-01-15' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.ts b/frontend/src/app/components/ui-components/record-view-fields/date/date.component.ts deleted file mode 100644 index ec1cd1e28..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/date/date.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { format } from 'date-fns'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-date-record-view', - templateUrl: './date.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './date.component.css'], - imports: [], -}) -export class DateRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'date'; - - public formattedDate: string; - - ngOnInit(): void { - if (this.value()) { - try { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - this.formattedDate = format(date, 'P'); - } else { - this.formattedDate = this.value(); - } - } catch (_error) { - this.formattedDate = this.value(); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.css b/frontend/src/app/components/ui-components/record-view-fields/file/file.component.css deleted file mode 100644 index 05f5eebc1..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.field-value { - font-style: italic; - color: #666; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.html b/frontend/src/app/components/ui-components/record-view-fields/file/file.component.html deleted file mode 100644 index b05b4f798..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.html +++ /dev/null @@ -1 +0,0 @@ -{{displayText}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/file/file.component.spec.ts deleted file mode 100644 index 710a61256..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FileRecordViewComponent } from './file.component'; - -describe('FileRecordViewComponent', () => { - let component: FileRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FileRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FileRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should identify blob objects', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [1, 2] }); - expect(component.isBlob).toBe(true); - }); - - it('should return Binary Data for blobs', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [1, 2] }); - expect(component.displayText).toBe('Binary Data'); - }); - - it('should return Binary Data for long strings', () => { - fixture.componentRef.setInput('value', 'a]bcdefghijklmnopqrstu'); - expect(component.displayText).toBe('Binary Data'); - }); - - it('should return the value for short strings', () => { - fixture.componentRef.setInput('value', 'short.txt'); - expect(component.displayText).toBe('short.txt'); - }); - - it('should return dash for null value', () => { - fixture.componentRef.setInput('value', null); - expect(component.displayText).toBe('—'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/file/file.component.stories.ts deleted file mode 100644 index ed76f9d26..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { FileRecordViewComponent } from './file.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/File', - component: FileRecordViewComponent, - args: { - key: 'field_name', - value: 'document.pdf', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'document.pdf' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.ts b/frontend/src/app/components/ui-components/record-view-fields/file/file.component.ts deleted file mode 100644 index 51d4a2001..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/file/file.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -interface Blob { - type: string; - data: any[]; -} - -@Component({ - selector: 'app-file-record-view', - templateUrl: './file.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './file.component.css'], - imports: [], -}) -export class FileRecordViewComponent extends BaseRecordViewFieldComponent { - get isBlob(): boolean { - return typeof this.value() === 'object' && this.value() !== null && 'type' in this.value() && 'data' in this.value(); - } - - get displayText(): string { - if (this.isBlob) { - return 'Binary Data'; - } else if (typeof this.value() === 'string' && this.value().length > 20) { - return 'Binary Data'; - } - return this.value() ? String(this.value()) : '—'; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.css b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.css deleted file mode 100644 index f18e255d5..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.foreign-key-link { - display: flex; - align-items: center; - text-decoration: none; -} - -@media (prefers-color-scheme: light) { - .foreign-key-link { - border-bottom: 1px solid rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .foreign-key-link { - border-bottom: 1px solid rgba(255, 255, 255, 0.64); - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html deleted file mode 100644 index d9083c910..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.html +++ /dev/null @@ -1,12 +0,0 @@ -@if (displayValue() == null) { - — -} @else { - - {{displayValue()}} - visibility - -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts deleted file mode 100644 index 518b9ade1..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { ForeignKeyRecordViewComponent } from './foreign-key.component'; - -describe('ForeignKeyRecordViewComponent', () => { - let component: ForeignKeyRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ForeignKeyRecordViewComponent], - providers: [provideRouter([])], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ForeignKeyRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set foreignKeyURLParams on init', () => { - fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); - component.ngOnInit(); - expect(component.foreignKeyURLParams).toEqual({ id: 1, mode: 'view' }); - }); - - it('should render a dash when displayValue is null', () => { - fixture.componentRef.setInput('link', '/foo'); - fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); - fixture.componentRef.setInput('displayValue', null); - fixture.detectChanges(); - - const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); - const span = fixture.nativeElement.querySelector('span.field-view-value'); - expect(anchor).toBeFalsy(); - expect(span?.textContent?.trim()).toBe('—'); - }); - - it('should render a dash when displayValue is undefined', () => { - fixture.componentRef.setInput('link', '/foo'); - fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); - fixture.componentRef.setInput('displayValue', undefined); - fixture.detectChanges(); - - const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); - const span = fixture.nativeElement.querySelector('span.field-view-value'); - expect(anchor).toBeFalsy(); - expect(span?.textContent?.trim()).toBe('—'); - }); - - it('should render the link (not a dash) when displayValue is 0', () => { - fixture.componentRef.setInput('link', '/foo'); - fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); - fixture.componentRef.setInput('displayValue', 0 as unknown as string); - fixture.detectChanges(); - - const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); - expect(anchor).toBeTruthy(); - expect(anchor.querySelector('span').textContent.trim()).toBe('0'); - }); - - it('should render the link (not a dash) when displayValue is empty string', () => { - fixture.componentRef.setInput('link', '/foo'); - fixture.componentRef.setInput('primaryKeysParams', { id: 1 }); - fixture.componentRef.setInput('displayValue', ''); - fixture.detectChanges(); - - const anchor = fixture.nativeElement.querySelector('a.foreign-key-link'); - expect(anchor).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.stories.ts deleted file mode 100644 index 442bf5e6d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ForeignKeyRecordViewComponent } from './foreign-key.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/ForeignKey', - component: ForeignKeyRecordViewComponent, - args: { - key: 'field_name', - value: '1', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '1' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.ts deleted file mode 100644 index d35fa1384..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/foreign-key/foreign-key.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, input, OnInit, output } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-foreign-key-record-view', - templateUrl: './foreign-key.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './foreign-key.component.css'], - imports: [MatIconModule, RouterModule], -}) -export class ForeignKeyRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - readonly link = input(); - readonly primaryKeysParams = input(); - readonly displayValue = input(); - - readonly onForeignKeyClick = output<{ foreignKey: any; value: string }>(); - - public foreignKeyURLParams: any; - - ngOnInit() { - this.foreignKeyURLParams = { ...this.primaryKeysParams(), mode: 'view' }; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.css b/frontend/src/app/components/ui-components/record-view-fields/id/id.component.css deleted file mode 100644 index 405dcba7f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.css +++ /dev/null @@ -1,9 +0,0 @@ -.field-display-id { - display: flex; - align-items: center; -} - -.field-value-id { - font-weight: 500; - flex-grow: 1; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.html b/frontend/src/app/components/ui-components/record-view-fields/id/id.component.html deleted file mode 100644 index 2e72a7973..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.html +++ /dev/null @@ -1 +0,0 @@ -{{value() || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/id/id.component.spec.ts deleted file mode 100644 index 249d8d8b2..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { IdRecordViewComponent } from './id.component'; - -describe('IdRecordViewComponent', () => { - let component: IdRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [IdRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(IdRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/id/id.component.stories.ts deleted file mode 100644 index 8d5cdb340..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { IdRecordViewComponent } from './id.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Id', - component: IdRecordViewComponent, - args: { - key: 'field_name', - value: '12345', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '12345' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.ts b/frontend/src/app/components/ui-components/record-view-fields/id/id.component.ts deleted file mode 100644 index ca33f2197..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/id/id.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-id-record-view', - templateUrl: './id.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './id.component.css'], - imports: [], -}) -export class IdRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.css b/frontend/src/app/components/ui-components/record-view-fields/image/image.component.css deleted file mode 100644 index dab143174..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.image-preview { - width: 100%; - height: auto; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.html b/frontend/src/app/components/ui-components/record-view-fields/image/image.component.html deleted file mode 100644 index 375785e8d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.html +++ /dev/null @@ -1,9 +0,0 @@ -@if (!isUrl) { - [Image URL] -} -@if (isUrl) { - Image -} -{{value() || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/image/image.component.spec.ts deleted file mode 100644 index 980a45d4a..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ImageRecordViewComponent } from './image.component'; - -describe('ImageRecordViewComponent', () => { - let component: ImageRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ImageRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ImageRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should return value as srcValue when no prefix', () => { - fixture.componentRef.setInput('value', 'image.png'); - fixture.componentRef.setInput('widgetStructure', undefined); - expect(component.srcValue).toBe('image.png'); - }); - - it('should prepend prefix to srcValue from widget params', () => { - fixture.componentRef.setInput('value', 'image.png'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.example.com/' } } as any); - expect(component.srcValue).toBe('https://cdn.example.com/image.png'); - }); - - it('should return true for isUrl when valid URL', () => { - fixture.componentRef.setInput('value', 'image.png'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { prefix: 'https://cdn.example.com/' } } as any); - expect(component.isUrl).toBe(true); - }); - - it('should return false for isUrl when invalid URL', () => { - fixture.componentRef.setInput('value', 'image.png'); - fixture.componentRef.setInput('widgetStructure', undefined); - expect(component.isUrl).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/image/image.component.stories.ts deleted file mode 100644 index 396d3048a..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ImageRecordViewComponent } from './image.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Image', - component: ImageRecordViewComponent, - args: { - key: 'field_name', - value: 'https://via.placeholder.com/150', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'https://via.placeholder.com/150' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.ts b/frontend/src/app/components/ui-components/record-view-fields/image/image.component.ts deleted file mode 100644 index b6ac25e49..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/image/image.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-image-record-view', - templateUrl: './image.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './image.component.css'], - imports: [], -}) -export class ImageRecordViewComponent extends BaseRecordViewFieldComponent { - get srcValue(): string { - if (!this.value()) return ''; - const prefix = this.widgetStructure()?.widget_params?.prefix || ''; - return prefix + this.value(); - } - - get isUrl(): boolean { - if (!this.value()) return false; - try { - // Check if the prefixed URL is valid - new URL(this.srcValue); - return true; - } catch { - return false; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.css b/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.css deleted file mode 100644 index 8f1be46a3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.css +++ /dev/null @@ -1,20 +0,0 @@ -:host { - width: 100%; -} - -.code-editor-box { - display: block; - border: 1px solid rgba(0, 0, 0, 0.38); - border-radius: 0; - margin-top: 4px; - margin-bottom: 20px; - /* overflow-y: auto; - resize: vertical; */ - overflow: auto; - resize: both; -} - -.code-editor-box ::ng-deep .ngs-code-editor { - width: 100%; - height: 100%; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.html deleted file mode 100644 index d89f394c2..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
- - -
diff --git a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.spec.ts deleted file mode 100644 index 2ab2029e9..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { JsonEditorRecordViewComponent } from './json-editor.component'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; - -describe('JsonEditorRecordViewComponent', () => { - let component: JsonEditorRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [JsonEditorRecordViewComponent], - providers: [ - { provide: UiSettingsService, useValue: { isDarkMode: false } }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(JsonEditorRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set JSON code model on init', () => { - fixture.componentRef.setInput('value', '{"key":"value"}'); - fixture.componentRef.setInput('key', 'test'); - component.ngOnInit(); - expect(component.codeModel).toEqual({ - language: 'json', - uri: 'test.json', - value: '{"key":"value"}', - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.stories.ts deleted file mode 100644 index 4b3104a82..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { JsonEditorRecordViewComponent } from './json-editor.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'json', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/JsonEditor', - component: JsonEditorRecordViewComponent, - args: { - key: 'field_name', - value: '{"key": "value"}', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '{"key": "value"}' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.ts deleted file mode 100644 index 205810467..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/json-editor/json-editor.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component } from '@angular/core'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-json-editor-record-view', - templateUrl: './json-editor.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './json-editor.component.css'], - imports: [CodeEditorModule], -}) -export class JsonEditorRecordViewComponent extends BaseRecordViewFieldComponent { - public codeModel: Object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - lineNumbers: 'off', - }; - public codeEditorTheme = 'vs-dark'; - - constructor(private _uiSettings: UiSettingsService) { - super(); - } - - ngOnInit(): void { - this.codeModel = { - language: 'json', - uri: `${this.key()}.json`, - value: this.value(), - }; - - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.css b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.css deleted file mode 100644 index ca86f863b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.language-flag { - margin-right: 6px; - font-size: 1.2em; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html deleted file mode 100644 index c09ca8c93..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - @if (showFlag() && languageFlag()) { - {{ languageFlag() }} - } - {{ languageName() }} - diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts deleted file mode 100644 index 00f7346b2..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { LanguageRecordViewComponent } from './language.component'; - -describe('LanguageRecordViewComponent', () => { - let component: LanguageRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LanguageRecordViewComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(LanguageRecordViewComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('value', 'en'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display language name from code', () => { - fixture.componentRef.setInput('value', 'en'); - fixture.detectChanges(); - expect(component.languageName()).toBe('English'); - }); - - it('should display em dash when value is empty', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - expect(component.languageName()).toBe('—'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.stories.ts deleted file mode 100644 index 4804c162f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LanguageRecordViewComponent } from './language.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Language', - component: LanguageRecordViewComponent, - args: { - key: 'field_name', - value: 'en', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'en' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts b/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts deleted file mode 100644 index 2a15ed68e..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/language/language.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, input, output } from '@angular/core'; -import { TableField, WidgetStructure } from 'src/app/models/table'; -import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; - -@Component({ - selector: 'app-language-record-view', - templateUrl: './language.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './language.component.css'], - imports: [CommonModule], -}) -export class LanguageRecordViewComponent { - static type = 'language'; - - readonly key = input(); - readonly value = input(); - readonly structure = input(); - readonly widgetStructure = input(); - readonly rowData = input>(); - readonly primaryKeys = input>(); - - readonly onCopyToClipboard = output(); - - readonly showFlag = computed(() => { - const ws = this.widgetStructure(); - if (!ws?.widget_params) return true; - try { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - return params.show_flag !== undefined ? params.show_flag : true; - } catch { - return true; - } - }); - - readonly languageName = computed(() => { - const val = this.value(); - if (!val) return '—'; - const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); - return language ? language.name : val; - }); - - readonly languageFlag = computed(() => { - const val = this.value(); - if (!val) return ''; - const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); - return language ? getLanguageFlag(language) : ''; - }); -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.css b/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.html deleted file mode 100644 index a071ebc00..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.html +++ /dev/null @@ -1 +0,0 @@ -{{value() || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.spec.ts deleted file mode 100644 index 442e573d1..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LongTextRecordViewComponent } from './long-text.component'; - -describe('LongTextRecordViewComponent', () => { - let component: LongTextRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LongTextRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LongTextRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.stories.ts deleted file mode 100644 index 497cd5564..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LongTextRecordViewComponent } from './long-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/LongText', - component: LongTextRecordViewComponent, - args: { - key: 'field_name', - value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.ts deleted file mode 100644 index 88e693289..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/long-text/long-text.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-long-text-record-view', - templateUrl: './long-text.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './long-text.component.css'], - imports: [], -}) -export class LongTextRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css deleted file mode 100644 index 8b4bb1a94..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.css +++ /dev/null @@ -1,96 +0,0 @@ -.markdown-container { - padding: 16px; - border-radius: 4px; - min-height: 100px; -} - -@media (prefers-color-scheme: light) { - .markdown-container { - background: #f9f9f9; - border: 1px solid #e0e0e0; - color: rgba(0, 0, 0, 0.64); - } -} - -@media (prefers-color-scheme: dark) { - .markdown-container { - background: #2a2a2a; - border: 1px solid #404040; - color: rgba(255, 255, 255, 0.64); - } -} - -.markdown-container ::ng-deep h1, -.markdown-container ::ng-deep h2, -.markdown-container ::ng-deep h3, -.markdown-container ::ng-deep h4, -.markdown-container ::ng-deep h5, -.markdown-container ::ng-deep h6 { - margin-top: 16px; - margin-bottom: 8px; -} - -.markdown-container ::ng-deep p { - margin-bottom: 8px; -} - -.markdown-container ::ng-deep ul, -.markdown-container ::ng-deep ol { - margin-left: 20px; - margin-bottom: 8px; -} - -.markdown-container ::ng-deep code { - padding: 2px 4px; - border-radius: 3px; - font-family: monospace; -} - -@media (prefers-color-scheme: light) { - .markdown-container ::ng-deep code { - background: #f5f5f5; - } -} - -@media (prefers-color-scheme: dark) { - .markdown-container ::ng-deep code { - background: #3a3a3a; - } -} - -.markdown-container ::ng-deep pre { - padding: 12px; - border-radius: 4px; - overflow-x: auto; -} - -@media (prefers-color-scheme: light) { - .markdown-container ::ng-deep pre { - background: #f5f5f5; - } -} - -@media (prefers-color-scheme: dark) { - .markdown-container ::ng-deep pre { - background: #3a3a3a; - } -} - -.markdown-container ::ng-deep blockquote { - padding-left: 16px; - margin-left: 0; -} - -@media (prefers-color-scheme: light) { - .markdown-container ::ng-deep blockquote { - border-left: 4px solid #ddd; - color: #666; - } -} - -@media (prefers-color-scheme: dark) { - .markdown-container ::ng-deep blockquote { - border-left: 4px solid #555; - color: #aaa; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html deleted file mode 100644 index 4270d38fd..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts deleted file mode 100644 index 113d9c9fc..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideMarkdown } from 'ngx-markdown'; -import { MarkdownRecordViewComponent } from './markdown.component'; - -describe('MarkdownRecordViewComponent', () => { - let component: MarkdownRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MarkdownRecordViewComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideMarkdown()], - }).compileComponents(); - - fixture = TestBed.createComponent(MarkdownRecordViewComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('value', '# Test Markdown'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.stories.ts deleted file mode 100644 index 3448ca2c0..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MarkdownRecordViewComponent } from './markdown.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Markdown', - component: MarkdownRecordViewComponent, - args: { - key: 'field_name', - value: '# Hello\nThis is **markdown**', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '# Hello\nThis is **markdown**' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts deleted file mode 100644 index bec5e93de..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/markdown/markdown.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { MarkdownModule } from 'ngx-markdown'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-markdown-record-view', - templateUrl: './markdown.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './markdown.component.css'], - imports: [MarkdownModule], -}) -export class MarkdownRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - public renderedMarkdown: string = ''; - - ngOnInit(): void { - this.renderedMarkdown = this.value() || ''; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.css b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.html b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.html deleted file mode 100644 index cba669daf..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.html +++ /dev/null @@ -1,3 +0,0 @@ - - {{formattedValue || '—'}} - diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts deleted file mode 100644 index f08d06baf..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MoneyRecordViewComponent } from './money.component'; - -describe('MoneyRecordViewComponent', () => { - let component: MoneyRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MoneyRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MoneyRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should format value with currency symbol', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD' }, - }); - fixture.componentRef.setInput('value', 42.5); - component.ngOnInit(); - expect(component.formattedValue).toContain('$'); - expect(component.formattedValue).toContain('42.50'); - }); - - it('should return empty string for null value', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD' }, - }); - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedValue).toBe(''); - }); - - it('should handle object value with amount and currency', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD' }, - }); - fixture.componentRef.setInput('value', { amount: 100, currency: 'EUR' }); - component.ngOnInit(); - expect(component.formattedValue).toContain('100.00'); - }); - - it('should divide cents to major units when cents=true (USD)', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD', cents: true }, - }); - fixture.componentRef.setInput('value', 2599); - component.ngOnInit(); - expect(component.formattedValue).toBe('$25.99'); - }); - - it('should not divide for zero-decimal currencies when cents=true (JPY)', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'JPY', cents: true }, - }); - fixture.componentRef.setInput('value', 1099); - component.ngOnInit(); - expect(component.formattedValue).toBe('¥1099'); - }); - - it('should render zero amount as $0.00 with cents=true', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD', cents: true }, - }); - fixture.componentRef.setInput('value', 0); - component.ngOnInit(); - expect(component.formattedValue).toBe('$0.00'); - }); - - it('should treat object amount as cents when cents=true', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD', cents: true }, - }); - fixture.componentRef.setInput('value', { amount: 1099, currency: 'EUR' }); - component.ngOnInit(); - expect(component.formattedValue).toContain('10.99'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.stories.ts deleted file mode 100644 index 2dfcb4bed..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MoneyRecordViewComponent } from './money.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'decimal', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Money', - component: MoneyRecordViewComponent, - args: { - key: 'field_name', - value: '$99.99', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '$99.99' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts b/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts deleted file mode 100644 index 2296b8b27..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/money/money.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { getCurrencyByCode, getCurrencyDecimalPlaces, getCurrencyMinorUnitFactor } from 'src/app/consts/currencies'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-money-record-view', - templateUrl: './money.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './money.component.css'], - imports: [], -}) -export class MoneyRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - public displayCurrency: string = ''; - public currencySymbol: string = ''; - - ngOnInit(): void { - // Get currency from widget params - this.displayCurrency = ''; - if (this.widgetStructure()?.widget_params?.default_currency) { - this.displayCurrency = this.widgetStructure().widget_params.default_currency; - const currency = getCurrencyByCode(this.displayCurrency); - this.currencySymbol = currency ? currency.symbol : ''; - } - } - - get formattedValue(): string { - const raw = this.value(); - if (raw == null || raw === '') { - return ''; - } - - let amount: number | string; - let currency: string = this.displayCurrency; - - if (typeof raw === 'object' && raw.amount !== undefined) { - amount = raw.amount; - if (raw.currency) { - currency = raw.currency; - const currencyObj = getCurrencyByCode(currency); - this.currencySymbol = currencyObj ? currencyObj.symbol : ''; - } - } else { - amount = raw; - } - - if (typeof amount === 'string') { - amount = parseFloat(amount); - } - - if (Number.isNaN(amount as number)) { - return ''; - } - - const cents = this.widgetStructure()?.widget_params?.cents === true; - let decimalPlaces: number; - if (cents) { - amount = (amount as number) / getCurrencyMinorUnitFactor(currency); - decimalPlaces = getCurrencyDecimalPlaces(currency); - } else { - decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2; - } - - return `${this.currencySymbol}${(amount as number).toFixed(decimalPlaces)}`; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.css b/frontend/src/app/components/ui-components/record-view-fields/number/number.component.css deleted file mode 100644 index 140aa7fbc..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.out-of-threshold { - display: flex; - align-items: center; - gap: 2px; -} - -.out-of-threshold__icon { - font-size: 16px; - width: 16px; - height: 16px; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.html b/frontend/src/app/components/ui-components/record-view-fields/number/number.component.html deleted file mode 100644 index d2dfc17a3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.html +++ /dev/null @@ -1,9 +0,0 @@ - - {{displayValue}} - @if (isOutOfThreshold === 'down') { - north - } - @if (isOutOfThreshold === 'up') { - south - } - diff --git a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/number/number.component.spec.ts deleted file mode 100644 index 779fe5c04..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NumberRecordViewComponent } from './number.component'; - -describe('NumberRecordViewComponent', () => { - let component: NumberRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NumberRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumberRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should display em dash for null value', () => { - fixture.componentRef.setInput('value', null); - expect(component.displayValue).toBe('—'); - }); - - it('should display number as string when no unit', () => { - fixture.componentRef.setInput('value', 42); - expect(component.displayValue).toBe('42'); - }); - - it('should return false for isOutOfThreshold when no thresholds', () => { - fixture.componentRef.setInput('value', '50'); - fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); - expect(component.isOutOfThreshold).toBe(false); - }); - - it('should return down when value below threshold_min', () => { - fixture.componentRef.setInput('value', '5'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { threshold_min: 10 } } as any); - expect(component.isOutOfThreshold).toBe('down'); - }); - - it('should return up when value above threshold_max', () => { - fixture.componentRef.setInput('value', '150'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { threshold_max: 100 } } as any); - expect(component.isOutOfThreshold).toBe('up'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/number/number.component.stories.ts deleted file mode 100644 index e264b500d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { NumberRecordViewComponent } from './number.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Number', - component: NumberRecordViewComponent, - args: { - key: 'field_name', - value: 42, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 42 }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.ts b/frontend/src/app/components/ui-components/record-view-fields/number/number.component.ts deleted file mode 100644 index 79993151b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/number/number.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import convert from 'convert'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-number-record-view', - templateUrl: './number.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './number.component.css'], - imports: [MatIconModule], -}) -export class NumberRecordViewComponent extends BaseRecordViewFieldComponent { - get displayValue(): string { - if (this.value() == null || this.value() === '') { - return '—'; - } - - const unit = this.widgetStructure()?.widget_params?.unit; - - if (!unit) { - return this.value().toString(); - } - - try { - const convertedValue = convert(parseFloat(this.value()), unit).to('best'); - // Format number to max 2 decimal places without trailing zeros - const formattedQuantity = parseFloat(convertedValue.quantity.toFixed(2)).toString(); - return `${formattedQuantity} ${convertedValue.unit}`; - } catch (error) { - console.warn('Unit conversion failed:', error); - return this.value().toString(); - } - } - - get isOutOfThreshold(): 'up' | 'down' | false { - if (this.value() == null || this.value() === '') { - return false; - } - - const thresholdMin = this.widgetStructure()?.widget_params?.threshold_min; - const thresholdMax = this.widgetStructure()?.widget_params?.threshold_max; - const numValue = parseFloat(this.value()); - - if (thresholdMin !== undefined && numValue < thresholdMin) { - return 'down'; - } - - if (thresholdMax !== undefined && numValue > thresholdMax) { - return 'up'; - } - - return false; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.css b/frontend/src/app/components/ui-components/record-view-fields/password/password.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.html b/frontend/src/app/components/ui-components/record-view-fields/password/password.component.html deleted file mode 100644 index a1e3ab35b..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.html +++ /dev/null @@ -1 +0,0 @@ -•••••••• diff --git a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/password/password.component.spec.ts deleted file mode 100644 index d4daee738..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PasswordRecordViewComponent } from './password.component'; - -describe('PasswordRecordViewComponent', () => { - let component: PasswordRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PasswordRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/password/password.component.stories.ts deleted file mode 100644 index 334f0fc4d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PasswordRecordViewComponent } from './password.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Password', - component: PasswordRecordViewComponent, - args: { - key: 'field_name', - value: '********', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '********' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.ts b/frontend/src/app/components/ui-components/record-view-fields/password/password.component.ts deleted file mode 100644 index 7eb00cf86..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/password/password.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-password-record-view', - templateUrl: './password.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './password.component.css'], - imports: [], -}) -export class PasswordRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.css b/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.css deleted file mode 100644 index 08fffb713..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.css +++ /dev/null @@ -1,15 +0,0 @@ -.phone-content { - display: flex; - align-items: center; - gap: 8px; -} - -.country-flag { - font-size: 16px; - cursor: help; - user-select: none; -} - -.phone-link { - color: var(--primary-color); -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.html deleted file mode 100644 index 15fbf3e5f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.html +++ /dev/null @@ -1,8 +0,0 @@ -@if (value()) { - - @if (countryFlag) { - {{ countryFlag }} - } - {{ formattedNumber || value() }} - -} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.spec.ts deleted file mode 100644 index 57f70fb50..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PhoneRecordViewComponent } from './phone.component'; - -describe('PhoneRecordViewComponent', () => { - let component: PhoneRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PhoneRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PhoneRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should parse valid phone number', () => { - fixture.componentRef.setInput('value', '+12025551234'); - component.ngOnInit(); - expect(component.formattedNumber).toBeDefined(); - expect(component.formattedNumber).not.toBe(''); - }); - - it('should handle invalid phone number gracefully', () => { - fixture.componentRef.setInput('value', 'not-a-phone'); - component.ngOnInit(); - expect(component.formattedNumber).toBe('not-a-phone'); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedNumber).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.stories.ts deleted file mode 100644 index 888c24e51..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PhoneRecordViewComponent } from './phone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Phone', - component: PhoneRecordViewComponent, - args: { - key: 'field_name', - value: '+1 (555) 123-4567', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '+1 (555) 123-4567' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.ts deleted file mode 100644 index 04db89a27..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/phone/phone.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { parsePhoneNumber } from 'libphonenumber-js'; -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-phone-record-view', - templateUrl: './phone.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './phone.component.css'], - imports: [MatTooltipModule], -}) -export class PhoneRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - public countryFlag: string = ''; - public countryName: string = ''; - public formattedNumber: string = ''; - - ngOnInit(): void { - this.parsePhoneNumber(); - } - - private parsePhoneNumber(): void { - if (!this.value() || typeof this.value() !== 'string') { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = ''; - return; - } - - try { - const phoneNumber = parsePhoneNumber(this.value()); - - if (phoneNumber?.country) { - const country = COUNTRIES.find((c) => c.code === phoneNumber.country); - - if (country) { - this.countryFlag = getCountryFlag(country.code); - this.countryName = country.name; - this.formattedNumber = phoneNumber.formatInternational(); - } else { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } else { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } catch (_error) { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.css b/frontend/src/app/components/ui-components/record-view-fields/point/point.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.html b/frontend/src/app/components/ui-components/record-view-fields/point/point.component.html deleted file mode 100644 index 54eb81845..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.html +++ /dev/null @@ -1,7 +0,0 @@ -@if (formattedPoint) { - - {{ formattedPoint }} - -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/point/point.component.spec.ts deleted file mode 100644 index 6598a0de0..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PointRecordViewComponent } from './point.component'; - -describe('PointRecordViewComponent', () => { - let component: PointRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PointRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PointRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should format string point', () => { - fixture.componentRef.setInput('value', '(1,2)'); - component.ngOnInit(); - expect(component.formattedPoint).toBe('(1, 2)'); - }); - - it('should format object point', () => { - fixture.componentRef.setInput('value', { x: 3, y: 4 }); - component.ngOnInit(); - expect(component.formattedPoint).toBe('(3, 4)'); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedPoint).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/point/point.component.stories.ts deleted file mode 100644 index f8ef70d17..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PointRecordViewComponent } from './point.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'point', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Point', - component: PointRecordViewComponent, - args: { - key: 'field_name', - value: '(40.7128, -74.0060)', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '(40.7128, -74.0060)' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.ts b/frontend/src/app/components/ui-components/record-view-fields/point/point.component.ts deleted file mode 100644 index d1d70dce7..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/point/point.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-point-record-view', - templateUrl: './point.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './point.component.css'], - imports: [MatTooltipModule], -}) -export class PointRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'point'; - - formattedPoint: string; - - ngOnInit() { - this.formatPoint(); - } - - private formatPoint() { - if (!this.value()) { - this.formattedPoint = ''; - return; - } - - try { - if (typeof this.value() === 'string') { - // Handle string format like "(x,y)" or "x,y" - const pointStr = this.value().trim().replace(/[()]/g, ''); - const [x, y] = pointStr.split(',').map((coord) => parseFloat(coord.trim())); - this.formattedPoint = `(${x}, ${y})`; - } else if (typeof this.value() === 'object') { - // Handle object format like {x: 1, y: 2} - const x = this.value().x || this.value()[0]; - const y = this.value().y || this.value()[1]; - this.formattedPoint = `(${x}, ${y})`; - } - } catch (_e) { - this.formattedPoint = String(this.value()); - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.css b/frontend/src/app/components/ui-components/record-view-fields/range/range.component.css deleted file mode 100644 index 0c680164f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.css +++ /dev/null @@ -1,64 +0,0 @@ -.range-display-container { - display: flex; - flex-direction: column; - gap: 4px; - padding: 4px 0; -} - -.range-value-label { - font-size: 14px; - color: rgba(0, 0, 0, 0.87); - font-weight: 500; -} - -.range-progress-bar { - height: 8px; - border-radius: 4px; - min-width: 160px; -} - -/* Material progress bar specific styles */ -.range-progress-bar.mat-mdc-progress-bar { - height: 8px; -} - -/* Light theme progress bar styles */ -.range-progress-bar { - --mat-progress-bar-active-indicator-color: var(--color-accentedPalette-300); - --mat-progress-bar-track-color: #e0e0e0; - --mat-progress-bar-active-indicator-height: 8px; - --mat-progress-bar-track-height: 8px; -} - -/* Override Material default styles */ -.range-progress-bar ::ng-deep .mdc-linear-progress { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__bar { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__bar-inner { - border-top-width: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__buffer { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__buffer-bar { - height: 8px !important; -} - -/* Dark theme styles */ -@media (prefers-color-scheme: dark) { - .range-value-label { - color: rgba(255, 255, 255, 0.87); - } - - .range-progress-bar { - --mat-progress-bar-active-indicator-color: var(--color-accentedPalette-700); - --mat-progress-bar-track-color: rgba(255, 255, 255, 0.12); - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.html b/frontend/src/app/components/ui-components/record-view-fields/range/range.component.html deleted file mode 100644 index 673c6a710..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
{{ displayValue }}
- - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/range/range.component.spec.ts deleted file mode 100644 index 15900122d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RangeRecordViewComponent } from './range.component'; - -describe('RangeRecordViewComponent', () => { - let component: RangeRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RangeRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RangeRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value with default max', () => { - fixture.componentRef.setInput('value', 50); - component.ngOnInit(); - expect(component.displayValue).toBe('50 / 100'); - }); - - it('should parse widget params for min/max', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { min: 0, max: 200 }, - }); - fixture.componentRef.setInput('value', 100); - component.ngOnInit(); - expect(component.displayValue).toBe('100 / 200'); - }); - - it('should calculate progress value', () => { - fixture.componentRef.setInput('value', 50); - component.ngOnInit(); - expect(component.getProgressValue()).toBe(50); - }); - - it('should clamp progress value', () => { - fixture.componentRef.setInput('value', 150); - component.ngOnInit(); - expect(component.getProgressValue()).toBe(100); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/range/range.component.stories.ts deleted file mode 100644 index f12d85400..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { RangeRecordViewComponent } from './range.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Range', - component: RangeRecordViewComponent, - args: { - key: 'field_name', - value: '[1, 100]' as any, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '[1, 100]' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.ts b/frontend/src/app/components/ui-components/record-view-fields/range/range.component.ts deleted file mode 100644 index da49ab3f7..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/range/range.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Component, input, OnChanges, OnInit } from '@angular/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-range-record-view', - standalone: true, - templateUrl: './range.component.html', - styleUrls: ['./range.component.css'], - imports: [MatProgressBarModule], -}) -export class RangeRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit, OnChanges { - override readonly value = input(); - static type = 'range'; - - public min: number = 0; - public max: number = 100; - public step: number = 1; - public displayValue: string = ''; - - ngOnInit(): void { - this._parseWidgetParams(); - this._updateDisplayValue(); - } - - ngOnChanges(): void { - this._parseWidgetParams(); - this._updateDisplayValue(); - } - - public getProgressValue(): number { - const numValue = Number(this.value()) || 0; - const range = this.max - this.min; - if (range === 0) return 0; - const progress = ((numValue - this.min) / range) * 100; - // Ensure progress is between 0 and 100 - return Math.max(0, Math.min(100, progress)); - } - - private _parseWidgetParams(): void { - console.log('Parsing widget params:', this.widgetStructure()?.widget_params); - if (this.widgetStructure()?.widget_params) { - try { - const params = this.widgetStructure().widget_params; - if (params.min !== undefined) { - this.min = Number(params.min) || 0; - } - if (params.max !== undefined) { - this.max = Number(params.max) || 100; - } - if (params.step !== undefined) { - this.step = Number(params.step) || 1; - } - } catch (error) { - console.error('Failed to parse widget params:', error); - } - } - console.log('Parsed widget params:', { min: this.min, max: this.max, step: this.step }); - } - - private _updateDisplayValue(): void { - const numValue = Number(this.value()) || 0; - this.displayValue = `${numValue} / ${this.max}`; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.css b/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.css deleted file mode 100644 index 652bb1e35..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.s3-preview { - width: 100%; - max-width: 300px; - height: auto; - margin-bottom: 8px; -} - -.s3-spinner { - display: block; - margin: 8px 0; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.html b/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.html deleted file mode 100644 index c2b400c42..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.html +++ /dev/null @@ -1,16 +0,0 @@ -@if (isImageType) { - @if (isLoading) { - - } - @if (previewUrl && !isLoading) { - S3 Image - } - @if (!previewUrl && !isLoading && value()) { - [S3 Image] - } -} @else { - {{value() || '—'}} -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.spec.ts deleted file mode 100644 index c7f4e6470..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { S3RecordViewComponent } from './s3.component'; - -describe('S3RecordViewComponent', () => { - let component: S3RecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [S3RecordViewComponent], - providers: [ - { - provide: S3Service, - useValue: { - getFileUrl: () => Promise.resolve({ url: 'https://s3.example.com/file.jpg' }), - }, - }, - { - provide: ConnectionsService, - useValue: { currentConnectionID: 'conn-1' }, - }, - { - provide: TablesService, - useValue: { currentTableName: 'test_table' }, - }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(S3RecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should parse widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - bucket: 'my-bucket', - type: 'file', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - }, - }); - component.ngOnInit(); - expect(component.params).toBeDefined(); - expect(component.params.bucket).toBe('my-bucket'); - }); - - it('should identify image type', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - bucket: 'my-bucket', - type: 'image', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - }, - }); - component.ngOnInit(); - expect(component.isImageType).toBe(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.stories.ts deleted file mode 100644 index 4c5dc9081..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { S3RecordViewComponent } from './s3.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/S3', - component: S3RecordViewComponent, - args: { - key: 'field_name', - value: 's3://bucket/file.txt', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 's3://bucket/file.txt' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.ts deleted file mode 100644 index 7b5a6e0c3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/s3/s3.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -type BucketProvider = 'aws' | 'digitalocean' | 'backblaze' | 'wasabi' | 'cloudflare-r2'; - -interface BucketWidgetParams { - provider?: BucketProvider; - bucket: string; - prefix?: string; - region?: string; - account_id?: string; - access_key_id: string; - access_key: string; - type?: 'file' | 'image'; -} - -@Component({ - selector: 'app-s3-record-view', - templateUrl: './s3.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './s3.component.css'], - imports: [MatProgressSpinnerModule], -}) -export class S3RecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - public params: BucketWidgetParams; - public previewUrl: string | null = null; - public isLoading: boolean = false; - - private connectionId: string; - private tableName: string; - - constructor( - private s3Service: S3Service, - private connectionsService: ConnectionsService, - private tablesService: TablesService, - ) { - super(); - } - - ngOnInit(): void { - this.connectionId = this.connectionsService.currentConnectionID; - this.tableName = this.tablesService.currentTableName; - this._parseWidgetParams(); - - if (this.value() && this.isImageType && this.primaryKeys()) { - this._loadPreview(); - } - } - - get isImageType(): boolean { - return this.params?.type === 'image'; - } - - private _parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - this.params = - typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - } catch (e) { - console.error('Error parsing S3 widget params:', e); - } - } - } - - private async _loadPreview(): Promise { - if (!this.value() || !this.connectionId || !this.tableName || !this.primaryKeys()) return; - - this.isLoading = true; - - const response = await this.s3Service.getFileUrl( - this.connectionId, - this.tableName, - this.widgetStructure().field_name, - this.primaryKeys(), - ); - - if (response) { - this.previewUrl = response.url; - } - - this.isLoading = false; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.css b/frontend/src/app/components/ui-components/record-view-fields/select/select.component.css deleted file mode 100644 index 8e5333b9f..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.field-view-value { - padding: 2px 6px; - border-radius: 4px; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.html b/frontend/src/app/components/ui-components/record-view-fields/select/select.component.html deleted file mode 100644 index 2fd355c75..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.html +++ /dev/null @@ -1 +0,0 @@ -{{displayValue}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/select/select.component.spec.ts deleted file mode 100644 index 92f2d2ba5..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SelectRecordViewComponent } from './select.component'; - -describe('SelectRecordViewComponent', () => { - let component: SelectRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SelectRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SelectRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display dash for null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.displayValue).toBe('—'); - }); - - it('should display option label when matching widget option', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [{ value: 'a', label: 'Alpha' }], - }, - }); - fixture.componentRef.setInput('value', 'a'); - component.ngOnInit(); - expect(component.displayValue).toBe('Alpha'); - }); - - it('should display raw value when no matching option', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [{ value: 'b', label: 'Beta' }], - }, - }); - fixture.componentRef.setInput('value', 'unknown'); - component.ngOnInit(); - expect(component.displayValue).toBe('unknown'); - }); - - it('should set backgroundColor from matching option', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [{ value: 'a', label: 'Alpha', background_color: '#ff0000' }], - }, - }); - fixture.componentRef.setInput('value', 'a'); - component.ngOnInit(); - expect(component.backgroundColor).toBe('#ff0000'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/select/select.component.stories.ts deleted file mode 100644 index 242993933..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { SelectRecordViewComponent } from './select.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'enum', - data_type_params: ['option1', 'option2', 'option3'], - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Select', - component: SelectRecordViewComponent, - args: { - key: 'field_name', - value: 'option1', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'option1' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.ts b/frontend/src/app/components/ui-components/record-view-fields/select/select.component.ts deleted file mode 100644 index b87d76f39..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/select/select.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-select-record-view', - templateUrl: './select.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './select.component.css'], - imports: [], -}) -export class SelectRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - public displayValue: string; - public backgroundColor: string; - - ngOnInit(): void { - this.setDisplayValue(); - } - - private setDisplayValue(): void { - if (!this.value()) { - this.displayValue = '—'; - return; - } - - if (this.widgetStructure()?.widget_params?.options) { - // Find the matching option based on value and use its label - const option = this.widgetStructure().widget_params.options.find( - (opt: { value: any; label: string }) => opt.value === this.value(), - ); - this.displayValue = option ? option.label : this.value(); - this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; - } else if (this.structure()?.data_type_params) { - // If no widget structure but we have data_type_params, just use the value - this.displayValue = this.value(); - } else { - this.displayValue = this.value(); - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.css b/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.html deleted file mode 100644 index a071ebc00..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.html +++ /dev/null @@ -1 +0,0 @@ -{{value() || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.spec.ts deleted file mode 100644 index 76f9ebc14..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StaticTextRecordViewComponent } from './static-text.component'; - -describe('StaticTextRecordViewComponent', () => { - let component: StaticTextRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StaticTextRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(StaticTextRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.stories.ts deleted file mode 100644 index 32a0d4fe9..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { StaticTextRecordViewComponent } from './static-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/StaticText', - component: StaticTextRecordViewComponent, - args: { - key: 'field_name', - value: 'Static content', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Static content' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.ts b/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.ts deleted file mode 100644 index b8f777ce3..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/static-text/static-text.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-static-text-record-view', - templateUrl: './static-text.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './static-text.component.css'], - imports: [], -}) -export class StaticTextRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.css b/frontend/src/app/components/ui-components/record-view-fields/text/text.component.css deleted file mode 100644 index 9ee904706..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.validation-error { - text-decoration: underline; - text-decoration-style: wavy; - text-underline-offset: 2px; -} - -@media (prefers-color-scheme: light) { - .validation-error { - color: var(--color-warnPalette-500); - } -} - -@media (prefers-color-scheme: dark) { - .validation-error { - color: var(--color-warnDarkPalette-500); - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.html b/frontend/src/app/components/ui-components/record-view-fields/text/text.component.html deleted file mode 100644 index 4f8475678..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.html +++ /dev/null @@ -1 +0,0 @@ -{{value() || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/text/text.component.spec.ts deleted file mode 100644 index 64a0c7917..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TextRecordViewComponent } from './text.component'; - -describe('TextRecordViewComponent', () => { - let component: TextRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TextRecordViewComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should return false for isInvalid when value is empty', () => { - fixture.componentRef.setInput('value', ''); - expect(component.isInvalid).toBe(false); - }); - - it('should return false for isInvalid when no validate widget param', () => { - fixture.componentRef.setInput('value', 'sometext'); - fixture.componentRef.setInput('widgetStructure', { widget_params: {} } as any); - expect(component.isInvalid).toBe(false); - }); - - it('should return true for isInvalid when email validation fails', () => { - fixture.componentRef.setInput('value', 'notanemail'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isEmail' } } as any); - expect(component.isInvalid).toBe(true); - }); - - it('should return false for isInvalid when email validation passes', () => { - fixture.componentRef.setInput('value', 'test@test.com'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isEmail' } } as any); - expect(component.isInvalid).toBe(false); - }); - - it('should return correct validationErrorMessage for isEmail', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'isEmail' } } as any); - expect(component.validationErrorMessage).toBe('Invalid email address'); - }); - - it('should validate regex pattern', () => { - fixture.componentRef.setInput('value', 'abc'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { validate: 'regex', regex: '^[0-9]+$' } } as any); - expect(component.isInvalid).toBe(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/text/text.component.stories.ts deleted file mode 100644 index 7395923e7..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TextRecordViewComponent } from './text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Text', - component: TextRecordViewComponent, - args: { - key: 'field_name', - value: 'Sample text', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Sample text' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.ts b/frontend/src/app/components/ui-components/record-view-fields/text/text.component.ts deleted file mode 100644 index 8d5f20ff7..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/text/text.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Component } from '@angular/core'; -import * as validator from 'validator'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-text-record-view', - templateUrl: './text.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './text.component.css'], - imports: [], -}) -export class TextRecordViewComponent extends BaseRecordViewFieldComponent { - get isInvalid(): boolean { - if (!this.value() || this.value() === '') { - return false; - } - - const validateType = this.widgetStructure()?.widget_params?.validate; - if (!validateType) { - return false; - } - - const stringValue = String(this.value()); - - // Special case for regex validation - if (validateType === 'regex') { - const regexPattern = this.widgetStructure()?.widget_params?.regex; - if (!regexPattern) { - return false; - } - try { - const regex = new RegExp(regexPattern); - return !regex.test(stringValue); - } catch (error) { - console.warn('Invalid regex pattern:', error); - return false; - } - } - - // Check if validator has this method - // biome-ignore lint: it is expected to import all exports of validator - const validatorMethod = validator[validateType]; - if (typeof validatorMethod !== 'function') { - console.warn(`Unknown validator method: ${validateType}`); - return false; - } - - try { - // Call the validator method and invert result (true if invalid) - return !validatorMethod(stringValue); - } catch (error) { - console.warn(`Validation error for ${validateType}:`, error); - return false; - } - } - - get validationErrorMessage(): string { - const validateType = this.widgetStructure()?.widget_params?.validate; - if (!validateType) { - return ''; - } - - if (validateType === 'regex') { - return 'Does not match the required pattern'; - } - - // Create user-friendly messages for common validators - const messages = { - isEmail: 'Invalid email address', - isURL: 'Invalid URL', - isIP: 'Invalid IP address', - isUUID: 'Invalid UUID', - isJSON: 'Invalid JSON', - isCreditCard: 'Invalid credit card number', - isISBN: 'Invalid ISBN', - isAlpha: 'Should contain only letters', - isNumeric: 'Should contain only numbers', - isAlphanumeric: 'Should contain only letters and numbers', - isHexColor: 'Invalid hex color', - isBase64: 'Invalid Base64 string', - isMobilePhone: 'Invalid mobile phone number', - isPostalCode: 'Invalid postal code', - }; - - return messages[validateType] || `Invalid ${validateType.replace(/^is/, '').toLowerCase()}`; - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.css b/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.html deleted file mode 100644 index 0f4b46264..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.html +++ /dev/null @@ -1 +0,0 @@ -{{ formattedInterval }} diff --git a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.spec.ts deleted file mode 100644 index a105e0ebc..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimeIntervalRecordViewComponent } from './time-interval.component'; - -describe('TimeIntervalRecordViewComponent', () => { - let component: TimeIntervalRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeIntervalRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeIntervalRecordViewComponent); - component = fixture.componentInstance; - // Don't call fixture.detectChanges() here - let individual tests set inputs first - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format interval object', () => { - fixture.componentRef.setInput('value', { hours: 2, minutes: 30 }); - component.ngOnInit(); - expect(component.formattedInterval).toBe('2h 30m'); - }); - - it('should display em dash for null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedInterval).toBe('—'); - }); - - it('should handle string JSON interval', () => { - fixture.componentRef.setInput('value', '{"hours":1,"minutes":15}'); - component.ngOnInit(); - expect(component.formattedInterval).toBe('1h 15m'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.stories.ts deleted file mode 100644 index f599db79c..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeIntervalRecordViewComponent } from './time-interval.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'interval', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/TimeInterval', - component: TimeIntervalRecordViewComponent, - args: { - key: 'field_name', - value: '01:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '01:30:00' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.ts deleted file mode 100644 index 5d413de73..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time-interval/time-interval.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-time-interval-record-view', - templateUrl: './time-interval.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './time-interval.component.css'], - imports: [], -}) -export class TimeIntervalRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - formattedInterval: string; - - ngOnInit() { - if (!this.value()) { - this.formattedInterval = '—'; - return; - } - - try { - const interval = typeof this.value() === 'string' ? JSON.parse(this.value()) : this.value(); - let parts = []; - - if (interval.days) parts.push(`${interval.days}d`); - if (interval.hours) parts.push(`${interval.hours}h`); - if (interval.minutes) parts.push(`${interval.minutes}m`); - if (interval.seconds) parts.push(`${interval.seconds}s`); - if (interval.milliseconds) parts.push(`${interval.milliseconds}ms`); - - this.formattedInterval = parts.length > 0 ? parts.join(' ') : '0'; - } catch (_e) { - this.formattedInterval = String(this.value()); - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.css b/frontend/src/app/components/ui-components/record-view-fields/time/time.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.html b/frontend/src/app/components/ui-components/record-view-fields/time/time.component.html deleted file mode 100644 index 5016beffb..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.html +++ /dev/null @@ -1 +0,0 @@ -{{formattedTime || '—'}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/time/time.component.spec.ts deleted file mode 100644 index 4fd51af40..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimeRecordViewComponent } from './time.component'; - -describe('TimeRecordViewComponent', () => { - let component: TimeRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeRecordViewComponent); - component = fixture.componentInstance; - // Don't call fixture.detectChanges() here - let individual tests set inputs first - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should preserve time string format', () => { - fixture.componentRef.setInput('value', '14:30:00'); - component.ngOnInit(); - expect(component.formattedTime).toBe('14:30:00'); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedTime).toBeUndefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/time/time.component.stories.ts deleted file mode 100644 index 15759b2e2..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeRecordViewComponent } from './time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'time', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Time', - component: TimeRecordViewComponent, - args: { - key: 'field_name', - value: '10:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '10:30:00' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.ts b/frontend/src/app/components/ui-components/record-view-fields/time/time.component.ts deleted file mode 100644 index 1d8f85260..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/time/time.component.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { format } from 'date-fns'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-time-record-view', - templateUrl: './time.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './time.component.css'], - imports: [], -}) -export class TimeRecordViewComponent extends BaseRecordViewFieldComponent implements OnInit { - static type = 'time'; - - public formattedTime: string; - - ngOnInit(): void { - if (this.value()) { - try { - if (this.value().includes(':')) { - // Handle time string format - this.formattedTime = this.value(); - } else { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - this.formattedTime = format(date, 'HH:mm:ss'); - } else { - this.formattedTime = this.value(); - } - } - } catch (_error) { - this.formattedTime = this.value(); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css deleted file mode 100644 index 99aa281f0..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.css +++ /dev/null @@ -1 +0,0 @@ -/* Timezone specific styles */ diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html deleted file mode 100644 index 025c37db6..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.html +++ /dev/null @@ -1 +0,0 @@ -{{formattedTimezone}} diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts deleted file mode 100644 index 5248c5dfb..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimezoneRecordViewComponent } from './timezone.component'; - -describe('TimezoneRecordViewComponent', () => { - let component: TimezoneRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimezoneRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimezoneRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display formatted timezone with UTC offset', () => { - fixture.componentRef.setInput('value', 'America/New_York'); - expect(component.formattedTimezone).toContain('America/New_York'); - expect(component.formattedTimezone).toContain('UTC'); - }); - - it('should display dash for null value', () => { - fixture.componentRef.setInput('value', null); - expect(component.formattedTimezone).toBe('—'); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.stories.ts deleted file mode 100644 index d1570a66c..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimezoneRecordViewComponent } from './timezone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Timezone', - component: TimezoneRecordViewComponent, - args: { - key: 'field_name', - value: 'UTC', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'UTC' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts deleted file mode 100644 index 23ca59272..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/timezone/timezone.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-timezone-record-view', - templateUrl: './timezone.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './timezone.component.css'], - imports: [], -}) -export class TimezoneRecordViewComponent extends BaseRecordViewFieldComponent { - get formattedTimezone(): string { - console.log('timezone', this.value()); - if (!this.value()) { - return '—'; - } - - try { - const offset = this.getTimezoneOffset(this.value()); - return `${this.value()} (UTC${offset})`; - } catch (_error) { - return this.value(); - } - } - - private getTimezoneOffset(timezone: string): string { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset', - }); - - const parts = formatter.formatToParts(now); - const offsetPart = parts.find((part) => part.type === 'timeZoneName'); - - if (offsetPart?.value.includes('GMT')) { - const offset = offsetPart.value.replace('GMT', ''); - return offset === '' ? '+00:00' : offset; - } - - // Fallback: calculate offset manually - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); - const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; - const hours = Math.floor(Math.abs(offsetMinutes) / 60); - const minutes = Math.abs(offsetMinutes) % 60; - const sign = offsetMinutes >= 0 ? '+' : '-'; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - } catch (error) { - console.error(error); - return ''; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.css b/frontend/src/app/components/ui-components/record-view-fields/url/url.component.css deleted file mode 100644 index c574bce09..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.css +++ /dev/null @@ -1,15 +0,0 @@ -.url-link { - display: flex; - align-items: center; - gap: 4px; - color: var(--color-primaryPalette-500); - word-break: break-all; -} - -.url-link__icon { - flex-shrink: 0; - margin-bottom: -2px; - width: 16px; - height: 16px; - font-size: 16px; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.html b/frontend/src/app/components/ui-components/record-view-fields/url/url.component.html deleted file mode 100644 index 284b29434..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.html +++ /dev/null @@ -1,11 +0,0 @@ -@if (isValidUrl) { - - link - {{value() || '—'}} - -} @else { -{{value() || '—'}} -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/url/url.component.spec.ts deleted file mode 100644 index 6207ac321..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { UrlRecordViewComponent } from './url.component'; - -describe('UrlRecordViewComponent', () => { - let component: UrlRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UrlRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UrlRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should return true for valid URL', () => { - fixture.componentRef.setInput('value', 'https://example.com'); - expect(component.isValidUrl).toBe(true); - }); - - it('should return false for invalid URL', () => { - fixture.componentRef.setInput('value', 'not-a-url'); - expect(component.isValidUrl).toBe(false); - }); - - it('should return false for empty value', () => { - fixture.componentRef.setInput('value', ''); - expect(component.isValidUrl).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/url/url.component.stories.ts deleted file mode 100644 index 08f6efa83..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UrlRecordViewComponent } from './url.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Url', - component: UrlRecordViewComponent, - args: { - key: 'field_name', - value: 'https://example.com', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'https://example.com' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.ts b/frontend/src/app/components/ui-components/record-view-fields/url/url.component.ts deleted file mode 100644 index 79c967e7d..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/url/url.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-url-record-view', - templateUrl: './url.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './url.component.css'], - imports: [MatIconModule], -}) -export class UrlRecordViewComponent extends BaseRecordViewFieldComponent { - static type = 'url'; - - get isValidUrl(): boolean { - if (!this.value()) return false; - try { - new URL(this.value()); - return true; - } catch { - return false; - } - } -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.css b/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.css deleted file mode 100644 index 37580a139..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.css +++ /dev/null @@ -1,7 +0,0 @@ -.uuid-text { - font-family: "Roboto Condensed", "Arial Narrow", sans-serif; - font-stretch: condensed; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.html b/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.html deleted file mode 100644 index d4fd04722..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.html +++ /dev/null @@ -1 +0,0 @@ -{{ value() || 'Null' }} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.spec.ts b/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.spec.ts deleted file mode 100644 index 3650282fd..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { UuidRecordViewComponent } from './uuid.component'; - -describe('UuidRecordViewComponent', () => { - let component: UuidRecordViewComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UuidRecordViewComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UuidRecordViewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.stories.ts b/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.stories.ts deleted file mode 100644 index 81cfadecf..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UuidRecordViewComponent } from './uuid.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'uuid', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Record View Fields/Uuid', - component: UuidRecordViewComponent, - args: { - key: 'field_name', - value: '550e8400-e29b-41d4-a716-446655440000', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '550e8400-e29b-41d4-a716-446655440000' }, - primaryKeys: { id: 1 }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.ts b/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.ts deleted file mode 100644 index 08ac526bf..000000000 --- a/frontend/src/app/components/ui-components/record-view-fields/uuid/uuid.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseRecordViewFieldComponent } from '../base-record-view-field/base-record-view-field.component'; - -@Component({ - selector: 'app-uuid-record-view', - templateUrl: './uuid.component.html', - styleUrls: ['../base-record-view-field/base-record-view-field.component.css', './uuid.component.css'], - imports: [], -}) -export class UuidRecordViewComponent extends BaseRecordViewFieldComponent {} diff --git a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.css b/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.css deleted file mode 100644 index 99c057261..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.css +++ /dev/null @@ -1,32 +0,0 @@ -.field-display { - position: relative; - display: flex; - align-items: center; -} - -.field-value { - flex-grow: 1 0 auto; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -} - -.field-copy-button { - position: absolute; - right: -6px; - top: 50%; - background-color: var(--hover-color); - transform: translate(0, -50%) scale(0.85); - opacity: 0; - transition: opacity 0.1s ease-in-out; -} - -.field-display:hover .field-copy-button { - opacity: 1; -} - -@media (width <= 600px) { - .field-copy-button { - display: none !important; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.html b/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.html deleted file mode 100644 index 67e1f2808..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.spec.ts deleted file mode 100644 index e3c7aa953..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BaseTableDisplayFieldComponent } from './base-table-display-field.component'; - -describe('BaseTableDisplayFieldComponent', () => { - let component: BaseTableDisplayFieldComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BaseTableDisplayFieldComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BaseTableDisplayFieldComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should accept value input', () => { - fixture.componentRef.setInput('value', 'test-value'); - fixture.detectChanges(); - expect(component.value()).toBe('test-value'); - }); - - it('should accept key input', () => { - fixture.componentRef.setInput('key', 'test-key'); - fixture.detectChanges(); - expect(component.key()).toBe('test-key'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.ts b/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.ts deleted file mode 100644 index f6fa5d07d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/base-table-display-field/base-table-display-field.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, input, output } from '@angular/core'; -import { TableField, WidgetStructure } from 'src/app/models/table'; - -@Component({ - selector: 'app-base-display-field', - templateUrl: './base-table-display-field.component.html', - styleUrl: './base-table-display-field.component.css', - imports: [], -}) -export class BaseTableDisplayFieldComponent { - readonly key = input(); - readonly value = input(); - readonly structure = input(); - readonly widgetStructure = input(); - readonly rowData = input>(); - readonly primaryKeys = input<{ column_name: string }[]>(); - - readonly onCopyToClipboard = output(); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.css b/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.css deleted file mode 100644 index 85157a202..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.static-text { - margin-bottom: 20px; -} - -.static-text__value { - color: #0a0a0a; - font-style: italic; - opacity: 0.32; - padding-left: 8px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.html b/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.html deleted file mode 100644 index 0e88e629b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- {{value() || '—'}} -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.spec.ts deleted file mode 100644 index 51fd74860..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BinaryDataCaptionDisplayComponent } from './binary-data-caption.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('BinaryDataCaptionDisplayComponent', () => { - let component: BinaryDataCaptionDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryDataCaptionDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BinaryDataCaptionDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 'binary-data-placeholder'); - fixture.detectChanges(); - expect(component.value()).toBe('binary-data-placeholder'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.stories.ts deleted file mode 100644 index c7965ee27..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BinaryDataCaptionDisplayComponent } from './binary-data-caption.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'bytea', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/BinaryDataCaption', - component: BinaryDataCaptionDisplayComponent, - args: { - key: 'field_name', - value: '(binary data)', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '(binary data)' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.ts b/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.ts deleted file mode 100644 index 77969b05d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary-data-caption/binary-data-caption.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-binary-data-caption-display', - templateUrl: './binary-data-caption.component.html', - styleUrls: [ - '../base-table-display-field/base-table-display-field.component.css', - './binary-data-caption.component.css', - ], - imports: [MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class BinaryDataCaptionDisplayComponent extends BaseTableDisplayFieldComponent {} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css deleted file mode 100644 index f33bcd18c..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.binary-value { - font-family: monospace; - font-size: 13px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html deleted file mode 100644 index 4ab62fd2b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
- {{ displayText() }} - @if (encodedValue()) { - - } -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts deleted file mode 100644 index 0d319a375..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { BinaryDisplayComponent } from './binary.component'; - -describe('BinaryDisplayComponent', () => { - let component: BinaryDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BinaryDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - - fixture = TestBed.createComponent(BinaryDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('shows em-dash for empty value', () => { - fixture.detectChanges(); - expect(component.displayText()).toBe('—'); - }); - - it('parses a server string as char-code-per-byte', () => { - fixture.componentRef.setInput('value', 'Hel'); - fixture.detectChanges(); - expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.encodedValue()).toBe('48656c'); - }); - - it('parses a Buffer-JSON value to a byte array', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x65, 0x6c] }); - fixture.detectChanges(); - expect(component.bytes()).toEqual([0x48, 0x65, 0x6c]); - expect(component.encodedValue()).toBe('48656c'); - }); - - it('truncates long hex at 20 chars', () => { - fixture.componentRef.setInput('value', 'ª'.repeat(30)); - fixture.detectChanges(); - expect(component.displayText()).toBe('aa'.repeat(10) + '…'); - }); - - describe('encoding param', () => { - it('defaults to hex when widgetStructure is absent', () => { - fixture.componentRef.setInput('value', 'Hi'); - fixture.detectChanges(); - expect(component.encoding()).toBe('hex'); - expect(component.encodedValue()).toBe('4869'); - }); - - it('renders base64 when encoding=base64', () => { - fixture.componentRef.setInput('value', 'Hello'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - fixture.detectChanges(); - expect(component.encoding()).toBe('base64'); - expect(component.encodedValue()).toBe('SGVsbG8='); - }); - - it('renders ascii when encoding=ascii', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [0x48, 0x00, 0x69] }); - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'ascii' } }); - fixture.detectChanges(); - expect(component.encoding()).toBe('ascii'); - expect(component.encodedValue()).toBe('H.i'); - }); - - it('ignores unknown encoding value and falls back to hex', () => { - fixture.componentRef.setInput('value', 'Hi'); - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'utf8' } }); - fixture.detectChanges(); - expect(component.encoding()).toBe('hex'); - expect(component.encodedValue()).toBe('4869'); - }); - - it('copyTooltip reflects the active encoding', () => { - fixture.componentRef.setInput('widgetStructure', { widget_params: { encoding: 'base64' } }); - fixture.detectChanges(); - expect(component.copyTooltip()).toBe('Copy base64'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts b/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts deleted file mode 100644 index f07a74762..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/binary/binary.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, computed } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { bytesToEncoded, isBinaryEncoding, parseBinaryValue } from 'src/app/lib/binary'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -const MAX_DISPLAY_LENGTH = 20; - -@Component({ - selector: 'app-binary-display', - templateUrl: './binary.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './binary.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class BinaryDisplayComponent extends BaseTableDisplayFieldComponent { - public readonly bytes = computed(() => parseBinaryValue(this.value())); - public readonly encoding = computed(() => { - const raw = this.widgetStructure()?.widget_params?.encoding; - return isBinaryEncoding(raw) ? raw : 'hex'; - }); - public readonly encodedValue = computed(() => bytesToEncoded(this.bytes(), this.encoding())); - public readonly copyTooltip = computed(() => `Copy ${this.encoding()}`); - - public readonly displayText = computed(() => { - const encoded = this.encodedValue(); - if (!encoded) return '—'; - return encoded.length > MAX_DISPLAY_LENGTH ? encoded.substring(0, MAX_DISPLAY_LENGTH) + '…' : encoded; - }); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.css b/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.css deleted file mode 100644 index 8aa0f1d9b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.css +++ /dev/null @@ -1,11 +0,0 @@ -.boolean-icon { - transform: scale(0.85); -} - -.boolean-icon-true { - color: var(--color-successPalette-500); -} - -.boolean-icon-false { - color: var(--color-warnPalette-500); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.html b/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.html deleted file mode 100644 index 52062abf7..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
-
- @if (value() === true) { - - check - - } @else if (value() === false) { - - close - - } @else { - — - } -
-
diff --git a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.spec.ts deleted file mode 100644 index 210882822..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BooleanDisplayComponent } from './boolean.component'; - -describe('BooleanDisplayComponent', () => { - let component: BooleanDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BooleanDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(BooleanDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should handle true value', () => { - fixture.componentRef.setInput('value', true); - expect(component.value()).toBe(true); - }); - - it('should handle false value', () => { - fixture.componentRef.setInput('value', false); - expect(component.value()).toBe(false); - }); - - it('should handle null value', () => { - fixture.componentRef.setInput('value', null); - expect(component.value()).toBeNull(); - }); - - it('should invert colors when configured', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { invert_colors: true }, - }); - expect(component.invertColors).toBe(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.stories.ts deleted file mode 100644 index 59f1395a9..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { BooleanDisplayComponent } from './boolean.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'boolean', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Boolean', - component: BooleanDisplayComponent, - args: { - key: 'field_name', - value: true, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: true }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.ts b/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.ts deleted file mode 100644 index f1dd65cdf..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/boolean/boolean.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-boolean', - templateUrl: './boolean.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './boolean.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule] -}) -export class BooleanDisplayComponent extends BaseTableDisplayFieldComponent { - get invertColors(): boolean { - // Parse widget parameters if available - if (this.widgetStructure()?.widget_params) { - const params = typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - return params?.invert_colors === true; - } - return false; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.css b/frontend/src/app/components/ui-components/table-display-fields/code/code.component.css deleted file mode 100644 index d3960a73f..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.field-value { - border-radius: 4px; - font-family: monospace; - padding: 0 8px; -} - -@media (prefers-color-scheme: light) { - .field-value { - background-color: #eaeaff; - } -} - -@media (prefers-color-scheme: dark) { - .field-value { - background-color: #2c2c54; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.html b/frontend/src/app/components/ui-components/table-display-fields/code/code.component.html deleted file mode 100644 index 479ddf07a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{value() || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/code/code.component.spec.ts deleted file mode 100644 index f88d56618..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CodeDisplayComponent } from './code.component'; - -describe('CodeDisplayComponent', () => { - let component: CodeDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CodeDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(CodeDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 'console.log("hello")'); - expect(component.value()).toBe('console.log("hello")'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - expect(component.value()).toBeNull(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/code/code.component.stories.ts deleted file mode 100644 index 9b6006bc8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CodeDisplayComponent } from './code.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Code', - component: CodeDisplayComponent, - args: { - key: 'field_name', - value: 'console.log("hello")', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'console.log("hello")' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.ts b/frontend/src/app/components/ui-components/table-display-fields/code/code.component.ts deleted file mode 100644 index 609418360..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/code/code.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-code-display', - templateUrl: './code.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './code.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class CodeDisplayComponent extends BaseTableDisplayFieldComponent {} diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.css b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.css deleted file mode 100644 index bd573c4fa..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.color-display { - display: flex; - align-items: center; - gap: 8px; -} - -.color-swatch { - width: 20px; - height: 20px; - border-radius: 4px; - /* border: 1px solid #ccc; */ - flex-shrink: 0; -} - -.field-value { - font-family: monospace; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.html b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.html deleted file mode 100644 index f3f75ac5d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
- @if (isValidColor) { -
- } - {{value() || '—'}} -
- -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts deleted file mode 100644 index 6026f618d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ColorDisplayComponent } from './color.component'; - -describe('ColorDisplayComponent', () => { - let component: ColorDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ColorDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ColorDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should detect valid color', () => { - fixture.componentRef.setInput('value', '#ff0000'); - fixture.detectChanges(); - - expect(component.isValidColor).toBe(true); - }); - - it('should detect invalid color', () => { - fixture.componentRef.setInput('value', 'not-a-color'); - fixture.detectChanges(); - - expect(component.isValidColor).toBe(false); - }); - - it('should normalize hex color', () => { - fixture.componentRef.setInput('value', 'ff0000'); - fixture.detectChanges(); - - expect(component.normalizedColorForDisplay).toBe('#ff0000'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.stories.ts deleted file mode 100644 index e7aced75d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ColorDisplayComponent } from './color.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Color', - component: ColorDisplayComponent, - args: { - key: 'field_name', - value: '#FF5733', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '#FF5733' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts b/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts deleted file mode 100644 index 7f83e1416..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/color/color.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import colorString from 'color-string'; - -@Component({ - selector: 'app-display-color', - templateUrl: './color.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './color.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class ColorDisplayComponent extends BaseTableDisplayFieldComponent { - get isValidColor(): boolean { - if (!this.value()) return false; - return this.parseColor(this.value()) !== null; - } - - get normalizedColorForDisplay(): string { - const parsed = this.parseColor(this.value()); - if (parsed) { - const [r, g, b] = parsed.value; - return `#${this.toHex(r)}${this.toHex(g)}${this.toHex(b)}`; - } - return '#000000'; - } - - - private parseColor(color: string): any { - if (!color) return null; - - // Try parsing with color-string - const parsed = colorString.get(color); - if (parsed) return parsed; - - // Try hex without hash - if (/^[A-Fa-f0-9]{6}$|^[A-Fa-f0-9]{3}$/.test(color)) { - return colorString.get('#' + color); - } - - return null; - } - - private toHex(n: number): string { - const hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } -} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.css b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.css deleted file mode 100644 index 1190f06e5..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.css +++ /dev/null @@ -1,27 +0,0 @@ -.field-wrapper { - display: flex; - align-items: center; -} - -.value-wrapper { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 300px; - display: flex; - align-items: center; -} - -.country-flag { - margin-right: 6px; - font-size: 1.2em; -} - -.copy-button { - visibility: hidden; - margin-left: 4px; -} - -.field-wrapper:hover .copy-button { - visibility: visible; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html deleted file mode 100644 index 0be43269a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
- - @if (showFlag && countryFlag) { - {{ countryFlag }} - } - {{ countryName }} - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.spec.ts deleted file mode 100644 index 68044fa3a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CountryDisplayComponent } from './country.component'; - -describe('CountryDisplayComponent', () => { - let component: CountryDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CountryDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(CountryDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should display country name from code', () => { - fixture.componentRef.setInput('value', 'US'); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.countryName).toBe('United States'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.countryName).toBe('\u2014'); - }); - - it('should respect show_flag widget param', () => { - fixture.componentRef.setInput('value', 'US'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { show_flag: false }, - }); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.showFlag).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.stories.ts deleted file mode 100644 index e282182a8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { CountryDisplayComponent } from './country.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Country', - component: CountryDisplayComponent, - args: { - key: 'field_name', - value: 'US', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'US' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.ts b/frontend/src/app/components/ui-components/table-display-fields/country/country.component.ts deleted file mode 100644 index 34fd469ce..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/country/country.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; -import { Component, OnInit, } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-country-display', - templateUrl: './country.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './country.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class CountryDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'country'; - - public countryName: string = ''; - public countryFlag: string = ''; - public showFlag: boolean = true; - - ngOnInit(): void { - this.parseWidgetParams(); - - if (this.value()) { - const country = COUNTRIES.find(c => c.code === this.value()); - this.countryName = country ? country.name : this.value(); - this.countryFlag = getCountryFlag(this.value()); - } else { - this.countryName = '—'; - this.countryFlag = ''; - } - } - - private parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - if (params.show_flag !== undefined) { - this.showFlag = params.show_flag; - } - } catch (e) { - console.error('Error parsing country widget params:', e); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.css b/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.html b/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.html deleted file mode 100644 index 3cd97b31e..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{formattedDateTime || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.spec.ts deleted file mode 100644 index e984f97f6..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DateTimeDisplayComponent } from './date-time.component'; -import { format } from 'date-fns'; - -describe('DateTimeDisplayComponent', () => { - let component: DateTimeDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateTimeDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateTimeDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format datetime value', () => { - const dateStr = '2023-04-29T10:30:00Z'; - fixture.componentRef.setInput('value', dateStr); - component.ngOnInit(); - fixture.detectChanges(); - - const expected = format(new Date(dateStr), 'P p'); - expect(component.formattedDateTime).toBe(expected); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedDateTime).toBeUndefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.stories.ts deleted file mode 100644 index 19b6ef2d8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateTimeDisplayComponent } from './date-time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'datetime', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/DateTime', - component: DateTimeDisplayComponent, - args: { - key: 'field_name', - value: '2024-01-15T10:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '2024-01-15T10:30:00' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.ts b/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.ts deleted file mode 100644 index a32be8e79..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date-time/date-time.component.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { format, formatDistanceToNow, differenceInHours } from 'date-fns'; - -@Component({ - selector: 'app-date-time-display', - templateUrl: './date-time.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './date-time.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class DateTimeDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'datetime'; - - public formattedDateTime: string; - public formatDistanceWithinHours: number = 0; - public fullDateTime: string; - - ngOnInit(): void { - this.parseWidgetParams(); - - if (this.value()) { - try { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - // Always store the full date/time format for tooltip - this.fullDateTime = format(date, "PPpp"); // e.g., "Apr 29, 2023 at 10:30 AM" - - // Check if formatDistanceWithinHours is enabled and date is within specified hours from now - if (this.formatDistanceWithinHours > 0 && this.isWithinHours(date, this.formatDistanceWithinHours)) { - this.formattedDateTime = formatDistanceToNow(date, { addSuffix: true }); - } else { - this.formattedDateTime = format(date, "P p"); - } - } else { - this.formattedDateTime = this.value(); - this.fullDateTime = this.value(); - } - } catch (_error) { - this.formattedDateTime = this.value(); - this.fullDateTime = this.value(); - } - } - } - - private parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - if (params.formatDistanceWithinHours !== undefined) { - this.formatDistanceWithinHours = Number(params.formatDistanceWithinHours) || 0; - } - } catch (e) { - console.error('Error parsing datetime widget params:', e); - } - } - } - - private isWithinHours(date: Date, hours: number): boolean { - const now = new Date(); - const hoursDifference = Math.abs(differenceInHours(date, now)); - return hoursDifference <= hours; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.css b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.html b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.html deleted file mode 100644 index 4168996cc..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{formattedDate || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts deleted file mode 100644 index dfd623835..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { format } from 'date-fns'; -import { DateDisplayComponent } from './date.component'; - -describe('DateDisplayComponent', () => { - let component: DateDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DateDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(DateDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format date value', () => { - const dateStr = '2023-04-29'; - fixture.componentRef.setInput('value', dateStr); - component.ngOnInit(); - fixture.detectChanges(); - - const expected = format(new Date(dateStr), 'P'); - expect(component.formattedDate).toBe(expected); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedDate).toBeUndefined(); - }); - - it('should display "today" instead of relative time for same-day date', () => { - const recentDate = new Date(Date.now() - 1000 * 60); // 1 minute ago — same calendar day - fixture.componentRef.setInput('value', recentDate.toISOString()); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { formatDistanceWithinHours: 48 }, - }); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedDate).toBe('today'); - }); - - it('should show relative date for non-today date within formatDistanceWithinHours', () => { - const yesterdayNoon = new Date(); - yesterdayNoon.setDate(yesterdayNoon.getDate() - 1); - yesterdayNoon.setHours(12, 0, 0, 0); - fixture.componentRef.setInput('value', yesterdayNoon.toISOString()); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { formatDistanceWithinHours: 48 }, - }); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedDate).toContain('ago'); - }); - - it('should expose exact date as tooltip via fullDate', () => { - const dateStr = '2023-04-29'; - fixture.componentRef.setInput('value', dateStr); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.fullDate).toBe(format(new Date(dateStr), 'PPP')); - }); - - it('should set fullDate tooltip even when displaying "today"', () => { - const recentDate = new Date(Date.now() - 1000 * 60); - fixture.componentRef.setInput('value', recentDate.toISOString()); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { formatDistanceWithinHours: 48 }, - }); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedDate).toBe('today'); - expect(component.fullDate).toBe(format(recentDate, 'PPP')); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.stories.ts deleted file mode 100644 index 52ea16f5c..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { DateDisplayComponent } from './date.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'date', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Date', - component: DateDisplayComponent, - args: { - key: 'field_name', - value: '2024-01-15', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '2024-01-15' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts b/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts deleted file mode 100644 index 9f30525be..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/date/date.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { differenceInHours, format, formatDistanceStrict, isToday, startOfToday } from 'date-fns'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-date-display', - templateUrl: './date.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './date.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class DateDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'date'; - - public formattedDate: string; - public formatDistanceWithinHours: number = 48; - public fullDate: string; - - ngOnInit(): void { - this.parseWidgetParams(); - - if (this.value()) { - try { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - // Always store the full date format for tooltip - this.fullDate = format(date, 'PPP'); // e.g., "April 29th, 2023" - - // Check if formatDistanceWithinHours is enabled and date is within specified hours from now - if (this.formatDistanceWithinHours > 0 && this.isWithinHours(date, this.formatDistanceWithinHours)) { - this.formattedDate = isToday(date) - ? 'today' - : formatDistanceStrict(date, startOfToday(), { addSuffix: true }); - } else { - this.formattedDate = format(date, 'P'); - } - } else { - this.formattedDate = this.value(); - this.fullDate = this.value(); - } - } catch (_error) { - this.formattedDate = this.value(); - this.fullDate = this.value(); - } - } - } - - private parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = - typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - - if (params.formatDistanceWithinHours !== undefined) { - this.formatDistanceWithinHours = Number(params.formatDistanceWithinHours) || 48; - } - } catch (e) { - console.error('Error parsing date widget params:', e); - } - } - } - - private isWithinHours(date: Date, hours: number): boolean { - const now = new Date(); - const hoursDifference = Math.abs(differenceInHours(date, now)); - return hoursDifference <= hours; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.css b/frontend/src/app/components/ui-components/table-display-fields/file/file.component.css deleted file mode 100644 index 05f5eebc1..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.field-value { - font-style: italic; - color: #666; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.html b/frontend/src/app/components/ui-components/table-display-fields/file/file.component.html deleted file mode 100644 index e490bcd7b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- {{displayText}} -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/file/file.component.spec.ts deleted file mode 100644 index 8b680dab3..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FileDisplayComponent } from './file.component'; - -describe('FileDisplayComponent', () => { - let component: FileDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FileDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(FileDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should detect blob data', () => { - fixture.componentRef.setInput('value', { type: 'Buffer', data: [1, 2, 3] }); - fixture.detectChanges(); - - expect(component.isBlob).toBe(true); - expect(component.displayText).toBe('Binary Data'); - }); - - it('should display short text value', () => { - fixture.componentRef.setInput('value', 'short.txt'); - fixture.detectChanges(); - - expect(component.isBlob).toBe(false); - expect(component.displayText).toBe('short.txt'); - }); - - it('should display Binary Data for long strings', () => { - fixture.componentRef.setInput('value', 'a'.repeat(25)); - fixture.detectChanges(); - - expect(component.displayText).toBe('Binary Data'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/file/file.component.stories.ts deleted file mode 100644 index b9eeac0b7..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { FileDisplayComponent } from './file.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/File', - component: FileDisplayComponent, - args: { - key: 'field_name', - value: 'document.pdf', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'document.pdf' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.ts b/frontend/src/app/components/ui-components/table-display-fields/file/file.component.ts deleted file mode 100644 index a7fd5c7bc..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/file/file.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -interface Blob { - type: string; - data: any[]; -} - -@Component({ - selector: 'app-file-display', - templateUrl: './file.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './file.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class FileDisplayComponent extends BaseTableDisplayFieldComponent { - get isBlob(): boolean { - return typeof this.value() === 'object' && this.value() !== null && 'type' in this.value() && 'data' in this.value(); - } - - get displayText(): string { - if (this.isBlob) { - return 'Binary Data'; - } else if (typeof this.value() === 'string' && this.value().length > 20) { - return 'Binary Data'; - } - return this.value() ? String(this.value()) : '—'; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.css b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.css deleted file mode 100644 index 5cd9a01db..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.css +++ /dev/null @@ -1,37 +0,0 @@ -.foreign-key-button { - background: transparent; - border: 1px solid transparent; - border-radius: 12px; - color: var(--color-accentedPalette-500); - cursor: pointer; - font-family: inherit; - font-size: inherit; - padding: 2px 8px; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; - max-width: 100%; - transition: - background 100ms, - border-color 100ms; -} - -@media (prefers-color-scheme: dark) { - .foreign-key-button:hover { - background-color: var(--color-accentedPalette-900); - border: 1px solid var(--color-accentedPalette-700); - } -} - -@media (prefers-color-scheme: light) { - .foreign-key-button:hover { - background-color: var(--color-accentedPalette-100); - border: 1px solid var(--color-accentedPalette-300); - } -} - -.foreign-key-button_selected { - background-color: var(--color-accentedPalette-100); - border-radius: 12px; - border: 1px solid var(--color-accentedPalette-300); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html deleted file mode 100644 index 66853cc22..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.html +++ /dev/null @@ -1,26 +0,0 @@ -
-
- @if (value() == null) { - — - } @else if (relations()) { - - } @else { - {{ value() }} - } -
- -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.spec.ts deleted file mode 100644 index 67224df7b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ForeignKeyDisplayComponent } from './foreign-key.component'; - -describe('ForeignKeyDisplayComponent', () => { - let component: ForeignKeyDisplayComponent; - let fixture: ComponentFixture; - - const relations = { - column_name: 'user_id', - constraint_name: 'fk_user', - referenced_column_name: 'id', - referenced_table_name: 'users', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ForeignKeyDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ForeignKeyDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', '42'); - expect(component.value()).toBe('42'); - }); - - it('should show foreign key button when relations exist', () => { - fixture.componentRef.setInput('value', '42'); - fixture.componentRef.setInput('relations', relations); - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('button.foreign-key-button'); - expect(button).toBeTruthy(); - }); - - it('should render a dash when value is null', () => { - fixture.componentRef.setInput('value', null); - fixture.componentRef.setInput('relations', relations); - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('button.foreign-key-button'); - const span = fixture.nativeElement.querySelector('.field-value span'); - expect(button).toBeFalsy(); - expect(span?.textContent?.trim()).toBe('—'); - }); - - it('should render a dash when value is undefined', () => { - fixture.componentRef.setInput('value', undefined); - fixture.componentRef.setInput('relations', relations); - fixture.detectChanges(); - const span = fixture.nativeElement.querySelector('.field-value span'); - expect(span?.textContent?.trim()).toBe('—'); - }); - - it('should render the FK button (not a dash) when value is 0', () => { - fixture.componentRef.setInput('value', 0); - fixture.componentRef.setInput('relations', relations); - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('button.foreign-key-button'); - expect(button).toBeTruthy(); - expect(button.textContent.trim()).toBe('0'); - }); - - it('should render the FK button (not a dash) when value is empty string', () => { - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('relations', relations); - fixture.detectChanges(); - const button = fixture.nativeElement.querySelector('button.foreign-key-button'); - expect(button).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.stories.ts deleted file mode 100644 index da17f7704..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ForeignKeyDisplayComponent } from './foreign-key.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/ForeignKey', - component: ForeignKeyDisplayComponent, - args: { - key: 'field_name', - value: '1', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '1' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.ts b/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.ts deleted file mode 100644 index 9af206720..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/foreign-key/foreign-key.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { Component, input, output } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TableForeignKey } from 'src/app/models/table'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-display-foreign-key', - templateUrl: './foreign-key.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './foreign-key.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule, CommonModule], -}) -export class ForeignKeyDisplayComponent extends BaseTableDisplayFieldComponent { - readonly isSelected = input(false); - readonly relations = input(); - - readonly onForeignKeyClick = output<{ foreignKey: any; value: string }>(); - - handleForeignKeyClick($event): void { - $event.stopPropagation(); - if (this.relations() && this.value() != null) { - this.onForeignKeyClick.emit({ - foreignKey: this.relations(), - value: this.value(), - }); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.css b/frontend/src/app/components/ui-components/table-display-fields/id/id.component.css deleted file mode 100644 index 1e1b6b2d1..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.css +++ /dev/null @@ -1,19 +0,0 @@ -:host { - text-align: right; -} - -.field-display-id { - display: flex; - align-items: center; - justify-content: flex-end; -} - -.field-value-id { - font-weight: 500; - flex-grow: 1; -} - -.field-copy-button { - right: auto; - left: 0; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.html b/frontend/src/app/components/ui-components/table-display-fields/id/id.component.html deleted file mode 100644 index d6ad3006b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.html +++ /dev/null @@ -1,12 +0,0 @@ -
- {{value() || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/id/id.component.spec.ts deleted file mode 100644 index 61d06a0e8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { IdDisplayComponent } from './id.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('IdDisplayComponent', () => { - let component: IdDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [IdDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(IdDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 42); - fixture.detectChanges(); - expect(component.value()).toBe(42); - }); - - it('should display dash for null value', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.field-value-id')?.textContent?.trim()).toBe('—'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/id/id.component.stories.ts deleted file mode 100644 index cbbeae09e..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { IdDisplayComponent } from './id.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Id', - component: IdDisplayComponent, - args: { - key: 'field_name', - value: '12345', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '12345' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.ts b/frontend/src/app/components/ui-components/table-display-fields/id/id.component.ts deleted file mode 100644 index 57f28e581..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/id/id.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-id', - templateUrl: './id.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './id.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class IdDisplayComponent extends BaseTableDisplayFieldComponent { -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.css b/frontend/src/app/components/ui-components/table-display-fields/image/image.component.css deleted file mode 100644 index 33e4f9686..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.css +++ /dev/null @@ -1,10 +0,0 @@ -.field-value { - font-style: italic; - color: #666; -} - -.image-thumbnail { - display: block; - height: var(--height); - margin: 12px 0; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.html b/frontend/src/app/components/ui-components/table-display-fields/image/image.component.html deleted file mode 100644 index 5c53abb9a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.html +++ /dev/null @@ -1,18 +0,0 @@ -
- @if (isUrl) { - Image - } @else { - [Image URL] - } - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/image/image.component.spec.ts deleted file mode 100644 index d4cca4d5a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ImageDisplayComponent } from './image.component'; - -describe('ImageDisplayComponent', () => { - let component: ImageDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ImageDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(ImageDisplayComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('widgetStructure', { widget_params: { height: 50 } }); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should detect valid URL', () => { - fixture.componentRef.setInput('value', 'https://example.com/image.png'); - fixture.detectChanges(); - - expect(component.isUrl).toBe(true); - }); - - it('should detect invalid URL', () => { - fixture.componentRef.setInput('value', 'not-a-url'); - fixture.detectChanges(); - - expect(component.isUrl).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/image/image.component.stories.ts deleted file mode 100644 index 7cee658d1..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { ImageDisplayComponent } from './image.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Image', - component: ImageDisplayComponent, - args: { - key: 'field_name', - value: 'https://via.placeholder.com/150', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'https://via.placeholder.com/150' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.ts b/frontend/src/app/components/ui-components/table-display-fields/image/image.component.ts deleted file mode 100644 index 61aad102e..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/image/image.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-image-display', - templateUrl: './image.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './image.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class ImageDisplayComponent extends BaseTableDisplayFieldComponent { - get srcValue(): string { - if (!this.value()) return ''; - const prefix = this.widgetStructure()?.widget_params?.prefix || ''; - return prefix + this.value(); - } - - get isUrl(): boolean { - if (!this.value()) return false; - try { - // Check if the prefixed URL is valid - new URL(this.srcValue); - return true; - } catch { - return false; - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.css b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.css deleted file mode 100644 index cbdf71e10..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.json-display { - border-radius: 4px; - font-family: monospace; - padding: 0 8px; -} - -@media (prefers-color-scheme: light) { - .json-display { - background-color: #eaeaff; - } -} - -@media (prefers-color-scheme: dark) { - .json-display { - background-color: #2c2c54; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.html b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.html deleted file mode 100644 index 3cb51fa26..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.html +++ /dev/null @@ -1,13 +0,0 @@ -@if (value()) { -
-
{{ formattedJson }}
- -
-} diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts deleted file mode 100644 index 57e6bac60..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { JsonEditorDisplayComponent } from './json-editor.component'; - -describe('JsonEditorDisplayComponent', () => { - let component: JsonEditorDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [JsonEditorDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(JsonEditorDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should format JSON value', () => { - fixture.componentRef.setInput('value', '{"name":"test","count":3}'); - expect(component.formattedJson).toBe(JSON.stringify({ name: 'test', count: 3 }, null, 2)); - }); - - it('should handle invalid JSON', () => { - fixture.componentRef.setInput('value', 'not valid json'); - expect(component.formattedJson).toBe('not valid json'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.stories.ts deleted file mode 100644 index dc4d11b6d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { JsonEditorDisplayComponent } from './json-editor.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'json', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/JsonEditor', - component: JsonEditorDisplayComponent, - args: { - key: 'field_name', - value: '{"key": "value"}', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '{"key": "value"}' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts b/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts deleted file mode 100644 index bee4bc5fa..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/json-editor/json-editor.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-json-editor-display', - templateUrl: './json-editor.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './json-editor.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class JsonEditorDisplayComponent extends BaseTableDisplayFieldComponent { - get formattedJson(): string { - if (!this.value()) return ''; - - try { - const parsedValue = typeof this.value() === 'string' ? JSON.parse(this.value()) : this.value(); - return JSON.stringify(parsedValue, null, 2); - } catch (_e) { - return String(this.value()); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.css b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.css deleted file mode 100644 index a140789cb..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.css +++ /dev/null @@ -1,27 +0,0 @@ -.field-wrapper { - display: flex; - align-items: center; -} - -.value-wrapper { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 300px; - display: flex; - align-items: center; -} - -.language-flag { - margin-right: 6px; - font-size: 1.2em; -} - -.copy-button { - visibility: hidden; - margin-left: 4px; -} - -.field-wrapper:hover .copy-button { - visibility: visible; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html deleted file mode 100644 index 4da896023..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
- - @if (showFlag() && languageFlag()) { - {{ languageFlag() }} - } - {{ languageName() }} - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.spec.ts deleted file mode 100644 index 9a5d4d5ad..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { LanguageDisplayComponent } from './language.component'; - -describe('LanguageDisplayComponent', () => { - let component: LanguageDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LanguageDisplayComponent, BrowserAnimationsModule], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(LanguageDisplayComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('value', 'en'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display language name from code', () => { - fixture.componentRef.setInput('value', 'fr'); - fixture.detectChanges(); - expect(component.languageName()).toBe('French'); - }); - - it('should display em dash when value is empty', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - expect(component.languageName()).toBe('—'); - }); - - it('should respect show_flag widget parameter', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { show_flag: false }, - }); - fixture.detectChanges(); - expect(component.showFlag()).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.stories.ts deleted file mode 100644 index 8afb31765..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LanguageDisplayComponent } from './language.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Language', - component: LanguageDisplayComponent, - args: { - key: 'field_name', - value: 'en', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'en' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts b/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts deleted file mode 100644 index 078e541f3..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/language/language.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, computed, input, output } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TableField, WidgetStructure } from 'src/app/models/table'; -import { getLanguageFlag, LANGUAGES } from '../../../../consts/languages'; - -@Component({ - selector: 'app-language-display', - templateUrl: './language.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './language.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class LanguageDisplayComponent { - static type = 'language'; - - readonly key = input(); - readonly value = input(); - readonly structure = input(); - readonly widgetStructure = input(); - readonly rowData = input>(); - readonly primaryKeys = input<{ column_name: string }[]>(); - - readonly onCopyToClipboard = output(); - - readonly showFlag = computed(() => { - const ws = this.widgetStructure(); - if (!ws?.widget_params) return true; - try { - const params = typeof ws.widget_params === 'string' ? JSON.parse(ws.widget_params) : ws.widget_params; - return params.show_flag !== undefined ? params.show_flag : true; - } catch { - return true; - } - }); - - readonly languageName = computed(() => { - const val = this.value(); - if (!val) return '—'; - const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); - return language ? language.name : val; - }); - - readonly languageFlag = computed(() => { - const val = this.value(); - if (!val) return ''; - const language = LANGUAGES.find((l) => l.code.toLowerCase() === val.toLowerCase()); - return language ? getLanguageFlag(language) : ''; - }); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.css b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.css deleted file mode 100644 index 218140c22..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.field-value { - height: 36px; - line-height: 36px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html deleted file mode 100644 index 7c16b17dc..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.html +++ /dev/null @@ -1,12 +0,0 @@ -
- {{value() || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.spec.ts deleted file mode 100644 index f7b66403b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LongTextDisplayComponent } from './long-text.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('LongTextDisplayComponent', () => { - let component: LongTextDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [LongTextDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(LongTextDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 'This is a long text value for testing'); - fixture.detectChanges(); - expect(component.value()).toBe('This is a long text value for testing'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - expect(component.value()).toBeNull(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.stories.ts deleted file mode 100644 index edebbb0d4..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { LongTextDisplayComponent } from './long-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/LongText', - component: LongTextDisplayComponent, - args: { - key: 'field_name', - value: 'Lorem ipsum dolor sit amet...', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Lorem ipsum dolor sit amet...' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.ts b/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.ts deleted file mode 100644 index c2f9847cc..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/long-text/long-text.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-long-text', - templateUrl: './long-text.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './long-text.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class LongTextDisplayComponent extends BaseTableDisplayFieldComponent { - -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css deleted file mode 100644 index ec5f93a9b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.css +++ /dev/null @@ -1,51 +0,0 @@ -.markdown-preview { - display: inline-block; - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.markdown-preview ::ng-deep p { - margin: 0; - display: inline; -} - -.markdown-preview ::ng-deep h1, -.markdown-preview ::ng-deep h2, -.markdown-preview ::ng-deep h3, -.markdown-preview ::ng-deep h4, -.markdown-preview ::ng-deep h5, -.markdown-preview ::ng-deep h6 { - margin: 0; - display: inline; - font-size: inherit; - font-weight: bold; -} - -.markdown-preview ::ng-deep ul, -.markdown-preview ::ng-deep ol { - display: inline; - list-style: none; - margin: 0; - padding: 0; -} - -.markdown-preview ::ng-deep code { - padding: 1px 3px; - border-radius: 2px; - font-family: monospace; - font-size: 0.9em; -} - -@media (prefers-color-scheme: light) { - .markdown-preview ::ng-deep code { - background: #f5f5f5; - } -} - -@media (prefers-color-scheme: dark) { - .markdown-preview ::ng-deep code { - background: #3a3a3a; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html deleted file mode 100644 index 6f7f66155..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
- - {{ displayTitle }} - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts deleted file mode 100644 index 746617c7b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideMarkdown } from 'ngx-markdown'; -import { MarkdownDisplayComponent } from './markdown.component'; - -describe('MarkdownDisplayComponent', () => { - let component: MarkdownDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MarkdownDisplayComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideMarkdown()], - }).compileComponents(); - - fixture = TestBed.createComponent(MarkdownDisplayComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('value', '# Test Markdown'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('Title extraction', () => { - it('should extract H1 heading as title', () => { - fixture.componentRef.setInput('value', '# Main Title\n\nSome paragraph text here.'); - component.ngOnInit(); - expect(component.displayTitle).toBe('Main Title'); - }); - - it('should extract H2 heading as title', () => { - fixture.componentRef.setInput('value', '## Secondary Title\n\nContent follows.'); - component.ngOnInit(); - expect(component.displayTitle).toBe('Secondary Title'); - }); - - it('should prioritize heading over paragraph', () => { - fixture.componentRef.setInput('value', 'Some intro text\n\n# Actual Title\n\nParagraph content.'); - component.ngOnInit(); - expect(component.displayTitle).toBe('Actual Title'); - }); - - it('should use first paragraph if no heading exists', () => { - fixture.componentRef.setInput('value', 'This is the first paragraph.\n\nThis is the second paragraph.'); - component.ngOnInit(); - expect(component.displayTitle).toBe('This is the first paragraph.'); - }); - - it('should truncate long titles at 100 characters', () => { - const longTitle = 'a'.repeat(150); - fixture.componentRef.setInput('value', `# ${longTitle}`); - component.ngOnInit(); - expect(component.displayTitle).toBe('a'.repeat(100) + '...'); - }); - - it('should handle markdown with bold and italic formatting', () => { - fixture.componentRef.setInput('value', '# **Bold** and *italic* title'); - component.ngOnInit(); - expect(component.displayTitle).toBe('**Bold** and *italic* title'); - }); - - it('should handle empty markdown', () => { - fixture.componentRef.setInput('value', ''); - component.ngOnInit(); - expect(component.displayTitle).toBe('—'); - }); - - it('should handle null/undefined markdown', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.displayTitle).toBe('—'); - }); - - it('should handle markdown with only whitespace', () => { - fixture.componentRef.setInput('value', ' \n\n '); - component.ngOnInit(); - expect(component.displayTitle).toBe('—'); - }); - - it('should extract title from markdown with code blocks', () => { - fixture.componentRef.setInput('value', '# API Documentation\n\n```javascript\nconst x = 5;\n```'); - component.ngOnInit(); - expect(component.displayTitle).toBe('API Documentation'); - }); - - it('should extract title from markdown with lists', () => { - fixture.componentRef.setInput('value', '# Shopping List\n\n- Item 1\n- Item 2'); - component.ngOnInit(); - expect(component.displayTitle).toBe('Shopping List'); - }); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.stories.ts deleted file mode 100644 index dfece3e97..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MarkdownDisplayComponent } from './markdown.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'text', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Markdown', - component: MarkdownDisplayComponent, - args: { - key: 'field_name', - value: '# Hello', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '# Hello' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts b/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts deleted file mode 100644 index e328e3454..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/markdown/markdown.component.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MarkdownModule } from 'ngx-markdown'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { marked } from 'marked'; - -@Component({ - selector: 'app-markdown-display', - templateUrl: './markdown.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './markdown.component.css'], - imports: [MarkdownModule, ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class MarkdownDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public renderedMarkdown: string = ''; - public displayTitle: string = ''; - - ngOnInit(): void { - this.renderedMarkdown = this.value() || ''; - this.displayTitle = this._extractTitle(this.value()); - } - - /** - * Extracts a title from markdown content using marked lexer - * Prioritizes headings, then falls back to first paragraph - * @param markdown The markdown content - * @returns Extracted and cleaned title string - */ - private _extractTitle(markdown: string): string { - if (!markdown || markdown.trim() === '') { - return '—'; - } - - try { - const tokens = marked.lexer(markdown); - - // First pass: look for headings (h1-h6) - for (const token of tokens) { - if (token.type === 'heading') { - return this._truncateText(token.text, 100); - } - } - - // Second pass: look for first paragraph or text - for (const token of tokens) { - if (token.type === 'paragraph') { - return this._truncateText(token.text, 100); - } - if (token.type === 'text' && token.text.trim().length > 0) { - return this._truncateText(token.text, 100); - } - } - - // If no heading or paragraph found, return first non-empty line - const firstLine = markdown.trim().split('\n')[0]; - return this._truncateText(firstLine, 100); - } catch (_error) { - // Fallback if parsing fails - const firstLine = markdown.trim().split('\n')[0]; - return this._truncateText(firstLine, 100); - } - } - - /** - * Truncates text to specified length and adds ellipsis if needed - * @param text Text to truncate - * @param maxLength Maximum length before truncation - * @returns Truncated text with ellipsis if applicable - */ - private _truncateText(text: string, maxLength: number): string { - const cleaned = text.trim(); - if (cleaned.length > maxLength) { - return cleaned.substring(0, maxLength) + '...'; - } - return cleaned || '—'; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.css b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.css deleted file mode 100644 index 8732bf81b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.css +++ /dev/null @@ -1,12 +0,0 @@ -:host { - text-align: right; -} - -.field-display { - justify-content: flex-end; -} - -.field-copy-button { - right: auto; - left: 0; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.html b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.html deleted file mode 100644 index 202929d6b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
- - {{formattedValue || '—'}} - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts deleted file mode 100644 index 8317798c6..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MoneyDisplayComponent } from './money.component'; - -describe('MoneyDisplayComponent', () => { - let component: MoneyDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MoneyDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(MoneyDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display formatted value with currency', () => { - fixture.componentRef.setInput('value', 42.5); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD' }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.formattedValue).toBe('$42.50'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - expect(component.formattedValue).toBe(''); - }); - - it('should divide cents to major units when cents=true (USD)', () => { - fixture.componentRef.setInput('value', 1099); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD', cents: true }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.formattedValue).toBe('$10.99'); - }); - - it('should not divide for zero-decimal currencies when cents=true (JPY)', () => { - fixture.componentRef.setInput('value', 1099); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'JPY', cents: true }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.formattedValue).toBe('¥1099'); - }); - - it('should render zero amount instead of empty string', () => { - fixture.componentRef.setInput('value', 0); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'USD', cents: true }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.formattedValue).toBe('$0.00'); - }); - - it('should ignore widget_params.decimal_places when cents=true and currency is zero-decimal', () => { - fixture.componentRef.setInput('value', 500); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { default_currency: 'JPY', cents: true, decimal_places: 4 }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.formattedValue).toBe('¥500'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.stories.ts deleted file mode 100644 index 9d2e8b157..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { MoneyDisplayComponent } from './money.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'numeric', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Money', - component: MoneyDisplayComponent, - args: { - key: 'field_name', - value: '$99.99', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '$99.99' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts b/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts deleted file mode 100644 index 942914171..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/money/money.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { getCurrencyByCode, getCurrencyDecimalPlaces, getCurrencyMinorUnitFactor } from 'src/app/consts/currencies'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-money-display', - templateUrl: './money.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './money.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class MoneyDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public displayCurrency: string = ''; - public currencySymbol: string = ''; - - ngOnInit(): void { - // Get currency from widget params - this.displayCurrency = ''; - if (this.widgetStructure()?.widget_params?.default_currency) { - this.displayCurrency = this.widgetStructure().widget_params.default_currency; - const currency = getCurrencyByCode(this.displayCurrency); - this.currencySymbol = currency ? currency.symbol : ''; - } - } - - get formattedValue(): string { - const raw = this.value(); - if (raw == null || raw === '') { - return ''; - } - - let amount: number | string; - let currency: string = this.displayCurrency; - - if (typeof raw === 'object' && raw.amount !== undefined) { - amount = raw.amount; - if (raw.currency) { - currency = raw.currency; - const currencyObj = getCurrencyByCode(currency); - this.currencySymbol = currencyObj ? currencyObj.symbol : ''; - } - } else { - amount = raw; - } - - if (typeof amount === 'string') { - amount = parseFloat(amount); - } - - if (Number.isNaN(amount as number)) { - return ''; - } - - const cents = this.widgetStructure()?.widget_params?.cents === true; - let decimalPlaces: number; - if (cents) { - amount = (amount as number) / getCurrencyMinorUnitFactor(currency); - decimalPlaces = getCurrencyDecimalPlaces(currency); - } else { - decimalPlaces = this.widgetStructure()?.widget_params?.decimal_places ?? 2; - } - - return `${this.currencySymbol}${(amount as number).toFixed(decimalPlaces)}`; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.css b/frontend/src/app/components/ui-components/table-display-fields/number/number.component.css deleted file mode 100644 index a4a50328f..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.css +++ /dev/null @@ -1,39 +0,0 @@ -:host { - text-align: right; -} - -.field-display { - justify-content: flex-end; -} - -.field-copy-button { - right: auto; - left: 0; -} - -@media (width <= 600px) { - :host { - text-align: left; - } - - .field-display { - justify-content: flex-start; - } - - .field-copy-button { - right: 0; - left: auto; - } -} - -.out-of-threshold { - display: flex; - align-items: center; - gap: 2px; -} - -.out-of-threshold__icon { - font-size: 16px; - width: 16px; - height: 16px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.html b/frontend/src/app/components/ui-components/table-display-fields/number/number.component.html deleted file mode 100644 index 3905c10bf..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
- - {{displayValue}} - @if (isOutOfThreshold === 'down') { - south - } - @if (isOutOfThreshold === 'up') { - north - } - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/number/number.component.spec.ts deleted file mode 100644 index ee7a2c353..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NumberDisplayComponent } from './number.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('NumberDisplayComponent', () => { - let component: NumberDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [NumberDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(NumberDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 42); - fixture.detectChanges(); - expect(component.displayValue).toBe('42'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - expect(component.displayValue).toBe('—'); - }); - - it('should detect out-of-threshold up', () => { - fixture.componentRef.setInput('value', 150); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { threshold_min: 0, threshold_max: 100 }, - }); - fixture.detectChanges(); - expect(component.isOutOfThreshold).toBe('up'); - }); - - it('should detect out-of-threshold down', () => { - fixture.componentRef.setInput('value', -5); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { threshold_min: 0, threshold_max: 100 }, - }); - fixture.detectChanges(); - expect(component.isOutOfThreshold).toBe('down'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/number/number.component.stories.ts deleted file mode 100644 index f60be74bb..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { NumberDisplayComponent } from './number.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Number', - component: NumberDisplayComponent, - args: { - key: 'field_name', - value: 42, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 42 }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.ts b/frontend/src/app/components/ui-components/table-display-fields/number/number.component.ts deleted file mode 100644 index 030f24eed..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/number/number.component.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import convert from 'convert'; - -@Component({ - selector: 'app-display-number', - templateUrl: './number.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './number.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class NumberDisplayComponent extends BaseTableDisplayFieldComponent { - - get displayValue(): string { - if (this.value() == null || this.value() === '') { - return '—'; - } - - const unit = this.widgetStructure()?.widget_params?.unit; - - if (!unit) { - return this.value().toString(); - } - - try { - const convertedValue = convert(parseFloat(this.value()), unit).to('best'); - // Format number to max 2 decimal places without trailing zeros - const formattedQuantity = parseFloat(convertedValue.quantity.toFixed(2)).toString(); - return `${formattedQuantity} ${convertedValue.unit}`; - } catch (error) { - console.warn('Unit conversion failed:', error); - return this.value().toString(); - } - } - - get isOutOfThreshold(): 'up' | 'down' | false { - if (this.value() == null || this.value() === '') { - return false; - } - - const thresholdMin = this.widgetStructure()?.widget_params?.threshold_min; - const thresholdMax = this.widgetStructure()?.widget_params?.threshold_max; - const numValue = parseFloat(this.value()); - - if (thresholdMin !== null && thresholdMax !== null && numValue < thresholdMin) { - return 'down'; - } - - if (thresholdMin && thresholdMax === null && numValue < thresholdMin) { - return 'down'; - } - - if (thresholdMax !== null && thresholdMin !== null && numValue > thresholdMax) { - return 'up'; - } - - if (thresholdMax && thresholdMin === null && numValue > thresholdMax) { - return 'up'; - } - - return false; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.css b/frontend/src/app/components/ui-components/table-display-fields/password/password.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.html b/frontend/src/app/components/ui-components/table-display-fields/password/password.component.html deleted file mode 100644 index bc4098081..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.html +++ /dev/null @@ -1,3 +0,0 @@ -
- •••••••• -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/password/password.component.spec.ts deleted file mode 100644 index 1d3bda323..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PasswordDisplayComponent } from './password.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('PasswordDisplayComponent', () => { - let component: PasswordDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PasswordDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PasswordDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display masked dots', () => { - fixture.componentRef.setInput('value', 'secret-password'); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.field-value')?.textContent?.trim()).toBe('••••••••'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/password/password.component.stories.ts deleted file mode 100644 index 5dfa8b36b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PasswordDisplayComponent } from './password.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Password', - component: PasswordDisplayComponent, - args: { - key: 'field_name', - value: '********', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '********' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.ts b/frontend/src/app/components/ui-components/table-display-fields/password/password.component.ts deleted file mode 100644 index 46d3dee98..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/password/password.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-password-display', - templateUrl: './password.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './password.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class PasswordDisplayComponent extends BaseTableDisplayFieldComponent {} diff --git a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.css b/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.css deleted file mode 100644 index d9cd2fb63..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.css +++ /dev/null @@ -1,20 +0,0 @@ -.field-display { - padding-right: 24px; -} - -.phone-content { - display: flex; - align-items: center; - gap: 8px; -} - -.country-flag { - font-size: 16px; - cursor: help; - user-select: none; -} - -.phone-link { - text-decoration: none; - color: var(--primary-color); -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.html b/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.html deleted file mode 100644 index daa8df936..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
- @if (value()) { - - @if (countryFlag) { - {{ countryFlag }} - } - {{ formattedNumber || value() }} - - } @else { - — - } - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.spec.ts deleted file mode 100644 index 303de79e7..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PhoneDisplayComponent } from './phone.component'; - -describe('PhoneDisplayComponent', () => { - let component: PhoneDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PhoneDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PhoneDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format phone number', () => { - fixture.componentRef.setInput('value', '+14155552671'); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedNumber).toBe('+1 415 555 2671'); - expect(component.countryName).toBe('United States'); - }); - - it('should display raw value for invalid number', () => { - fixture.componentRef.setInput('value', '12345'); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedNumber).toBe('12345'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.stories.ts deleted file mode 100644 index b10c718f5..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PhoneDisplayComponent } from './phone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Phone', - component: PhoneDisplayComponent, - args: { - key: 'field_name', - value: '+1 (555) 123-4567', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '+1 (555) 123-4567' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.ts b/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.ts deleted file mode 100644 index 50e2bdcef..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/phone/phone.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { parsePhoneNumber } from 'libphonenumber-js'; -import { COUNTRIES, getCountryFlag } from '../../../../consts/countries'; - -@Component({ - selector: 'app-phone-display', - templateUrl: './phone.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './phone.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class PhoneDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public countryFlag: string = ''; - public countryName: string = ''; - public formattedNumber: string = ''; - - ngOnInit(): void { - this.parsePhoneNumber(); - } - - private parsePhoneNumber(): void { - if (!this.value() || typeof this.value() !== 'string') { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = ''; - return; - } - - try { - const phoneNumber = parsePhoneNumber(this.value()); - - if (phoneNumber?.country) { - const country = COUNTRIES.find(c => c.code === phoneNumber.country); - - if (country) { - this.countryFlag = getCountryFlag(country.code); - this.countryName = country.name; - this.formattedNumber = phoneNumber.formatInternational(); - } else { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } else { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } catch (_error) { - this.countryFlag = ''; - this.countryName = ''; - this.formattedNumber = this.value(); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.css b/frontend/src/app/components/ui-components/table-display-fields/point/point.component.css deleted file mode 100644 index e6f38b6e6..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.css +++ /dev/null @@ -1,21 +0,0 @@ -.field-wrapper { - display: flex; - align-items: center; -} - -.value-wrapper { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 300px; - font-family: monospace; -} - -.copy-button { - visibility: hidden; - margin-left: 4px; -} - -.field-wrapper:hover .copy-button { - visibility: visible; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.html b/frontend/src/app/components/ui-components/table-display-fields/point/point.component.html deleted file mode 100644 index 394c892b9..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.html +++ /dev/null @@ -1,13 +0,0 @@ -@if (formattedPoint) { -
- {{ formattedPoint }} - -
-} diff --git a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/point/point.component.spec.ts deleted file mode 100644 index 65d2df7f4..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CommonModule } from '@angular/common'; -import { PointDisplayComponent } from './point.component'; - -describe('PointDisplayComponent', () => { - let component: PointDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PointDisplayComponent], - }) - .overrideComponent(PointDisplayComponent, { - add: { imports: [CommonModule] }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PointDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should format point string', () => { - fixture.componentRef.setInput('value', '(3.5, 7.2)'); - component.ngOnInit(); - expect(component.formattedPoint).toBe('(3.5, 7.2)'); - }); - - it('should format point object', () => { - fixture.componentRef.setInput('value', { x: 10, y: 20 }); - component.ngOnInit(); - expect(component.formattedPoint).toBe('(10, 20)'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - expect(component.formattedPoint).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/point/point.component.stories.ts deleted file mode 100644 index af366b968..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { PointDisplayComponent } from './point.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'point', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Point', - component: PointDisplayComponent, - args: { - key: 'field_name', - value: '(40.7128, -74.0060)', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '(40.7128, -74.0060)' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.ts b/frontend/src/app/components/ui-components/table-display-fields/point/point.component.ts deleted file mode 100644 index 884211541..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/point/point.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-point-display', - templateUrl: './point.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './point.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class PointDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'point'; - - formattedPoint: string; - - ngOnInit() { - this.formatPoint(); - } - - private formatPoint() { - if (!this.value()) { - this.formattedPoint = ''; - return; - } - - try { - if (typeof this.value() === 'string') { - // Handle string format like "(x,y)" or "x,y" - const pointStr = this.value().trim().replace(/[()]/g, ''); - const [x, y] = pointStr.split(',').map(coord => parseFloat(coord.trim())); - this.formattedPoint = `(${x}, ${y})`; - } else if (typeof this.value() === 'object') { - // Handle object format like {x: 1, y: 2} - const x = this.value().x || this.value()[0]; - const y = this.value().y || this.value()[1]; - this.formattedPoint = `(${x}, ${y})`; - } - } catch (_e) { - this.formattedPoint = String(this.value()); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.css b/frontend/src/app/components/ui-components/table-display-fields/range/range.component.css deleted file mode 100644 index 8a428ec78..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.css +++ /dev/null @@ -1,63 +0,0 @@ -.range-display-container { - display: flex; - flex-direction: column; - gap: 4px; - padding: 4px 0; -} - -.range-value-label { - font-size: 14px; - color: rgba(0, 0, 0, 0.87); - font-weight: 500; -} - -.range-progress-bar { - height: 8px; - border-radius: 4px; -} - -/* Material progress bar specific styles */ -.range-progress-bar.mat-mdc-progress-bar { - height: 8px; -} - -/* Light theme progress bar styles */ -.range-progress-bar { - --mat-progress-bar-active-indicator-color: var(--color-accentedPalette-300); - --mat-progress-bar-track-color: #e0e0e0; - --mat-progress-bar-active-indicator-height: 8px; - --mat-progress-bar-track-height: 8px; -} - -/* Override Material default styles */ -.range-progress-bar ::ng-deep .mdc-linear-progress { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__bar { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__bar-inner { - border-top-width: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__buffer { - height: 8px !important; -} - -.range-progress-bar ::ng-deep .mdc-linear-progress__buffer-bar { - height: 8px !important; -} - -/* Dark theme styles */ -@media (prefers-color-scheme: dark) { - .range-value-label { - color: rgba(255, 255, 255, 0.87); - } - - .range-progress-bar { - --mat-progress-bar-active-indicator-color: var(--color-accentedPalette-700); - --mat-progress-bar-track-color: rgba(255, 255, 255, 0.12); - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.html b/frontend/src/app/components/ui-components/table-display-fields/range/range.component.html deleted file mode 100644 index 673c6a710..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
{{ displayValue }}
- - -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/range/range.component.spec.ts deleted file mode 100644 index be9b0a81f..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RangeDisplayComponent } from './range.component'; - -describe('RangeDisplayComponent', () => { - let component: RangeDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RangeDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RangeDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value with max', () => { - fixture.componentRef.setInput('value', 50); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('50 / 100'); - }); - - it('should parse min/max from widget params', () => { - fixture.componentRef.setInput('value', 75); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { min: 0, max: 200, step: 5 }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.min).toBe(0); - expect(component.max).toBe(200); - expect(component.step).toBe(5); - expect(component.displayValue).toBe('75 / 200'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/range/range.component.stories.ts deleted file mode 100644 index ba8aa39db..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { RangeDisplayComponent } from './range.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Range', - component: RangeDisplayComponent, - args: { - key: 'field_name', - value: 50 as any, - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 50 }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.ts b/frontend/src/app/components/ui-components/table-display-fields/range/range.component.ts deleted file mode 100644 index 2238109bc..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/range/range.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Component, input, OnInit, OnChanges } from '@angular/core'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; - -@Component({ - selector: 'app-range-display', - standalone: true, - templateUrl: './range.component.html', - styleUrls: ['./range.component.css'], - imports: [ - MatProgressBarModule - ], -}) -export class RangeDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit, OnChanges { - override readonly value = input(); - static type = 'range'; - - public min: number = 0; - public max: number = 100; - public step: number = 1; - public displayValue: string = ''; - - ngOnInit(): void { - this._parseWidgetParams(); - this._updateDisplayValue(); - } - - ngOnChanges(): void { - this._parseWidgetParams(); - this._updateDisplayValue(); - } - - public getProgressValue(): number { - const numValue = Number(this.value()) || 0; - const range = this.max - this.min; - if (range === 0) return 0; - const progress = ((numValue - this.min) / range) * 100; - // Ensure progress is between 0 and 100 - return Math.max(0, Math.min(100, progress)); - } - - private _parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - const params = this.widgetStructure().widget_params; - if (params.min !== undefined) { - this.min = Number(params.min) || 0; - } - if (params.max !== undefined) { - this.max = Number(params.max) || 100; - } - if (params.step !== undefined) { - this.step = Number(params.step) || 1; - } - } catch (error) { - console.error('Failed to parse widget params:', error); - } - } - } - - private _updateDisplayValue(): void { - const numValue = Number(this.value()) || 0; - this.displayValue = `${numValue} / ${this.max}`; - } -} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.css b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.css deleted file mode 100644 index e25649dba..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.field-value { - font-style: italic; - color: #666; -} - -.s3-thumbnail { - display: block; - max-height: 60px; - max-width: 100px; - margin: 4px 0; - object-fit: contain; -} - -.s3-spinner { - display: inline-block; - margin: 4px 0; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.html b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.html deleted file mode 100644 index b55dc6cb2..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.html +++ /dev/null @@ -1,27 +0,0 @@ -
- @if (isImageType) { - @if (isLoading) { - - } @else if (previewUrl) { - S3 Image - } @else if (value()) { - [S3 Image] - } - } @else { - {{value() || '—'}} - } - - @if (value()) { - - } -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts deleted file mode 100644 index d2007fffb..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { vi } from 'vitest'; -import { S3DisplayComponent } from './s3.component'; - -describe('S3DisplayComponent', () => { - let component: S3DisplayComponent; - let fixture: ComponentFixture; - - const mockS3Service: Partial = { - getFileUrl: vi.fn(), - }; - - const mockConnectionsService: Partial = { - currentConnectionID: 'test-conn', - }; - - const mockTablesService: Partial = { - currentTableName: 'test-table', - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [S3DisplayComponent], - providers: [ - { provide: S3Service, useValue: mockS3Service }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: TablesService, useValue: mockTablesService }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(S3DisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should detect image type from widget params', () => { - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - bucket: 'my-bucket', - access_key_id: 'COMPANY-SECRET/bucket-access-key-id', - access_key: 'COMPANY-SECRET/bucket-access-key', - type: 'image', - }, - }); - component.ngOnInit(); - expect(component.isImageType).toBe(true); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.stories.ts deleted file mode 100644 index f5ef97c18..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { S3DisplayComponent } from './s3.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/S3', - component: S3DisplayComponent, - args: { - key: 'field_name', - value: 's3://bucket/file.txt', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 's3://bucket/file.txt' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts b/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts deleted file mode 100644 index b945a2581..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/s3/s3.component.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { S3Service } from 'src/app/services/s3.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -type BucketProvider = 'aws' | 'digitalocean' | 'backblaze' | 'wasabi' | 'cloudflare-r2'; - -interface BucketWidgetParams { - provider?: BucketProvider; - bucket: string; - prefix?: string; - region?: string; - account_id?: string; - access_key_id: string; - access_key: string; - type?: 'file' | 'image'; -} - -@Component({ - selector: 'app-s3-display', - templateUrl: './s3.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './s3.component.css'], - imports: [ClipboardModule, MatButtonModule, MatIconModule, MatProgressSpinnerModule, MatTooltipModule], -}) -export class S3DisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public params: BucketWidgetParams; - public previewUrl: string | null = null; - public isLoading: boolean = false; - - private connectionId: string; - private tableName: string; - - constructor( - private s3Service: S3Service, - private connectionsService: ConnectionsService, - private tablesService: TablesService, - ) { - super(); - } - - ngOnInit(): void { - this.connectionId = this.connectionsService.currentConnectionID; - this.tableName = this.tablesService.currentTableName; - this._parseWidgetParams(); - - if (this.value() && this.isImageType && this.rowPrimaryKey) { - this._loadPreview(); - } - } - - get isImageType(): boolean { - return this.params?.type === 'image'; - } - - get rowPrimaryKey(): Record | null { - if (!this.rowData() || !this.primaryKeys()) return null; - - const primaryKey: Record = {}; - for (const pk of this.primaryKeys()) { - primaryKey[pk.column_name] = this.rowData()[pk.column_name]; - } - return primaryKey; - } - - private _parseWidgetParams(): void { - if (this.widgetStructure()?.widget_params) { - try { - this.params = - typeof this.widgetStructure().widget_params === 'string' - ? JSON.parse(this.widgetStructure().widget_params as unknown as string) - : this.widgetStructure().widget_params; - } catch (e) { - console.error('Error parsing S3 widget params:', e); - } - } - } - - private async _loadPreview(): Promise { - const primaryKey = this.rowPrimaryKey; - if (!this.value() || !this.connectionId || !this.tableName || !primaryKey) return; - - this.isLoading = true; - - const response = await this.s3Service.getFileUrl( - this.connectionId, - this.tableName, - this.widgetStructure().field_name, - primaryKey, - ); - - if (response) { - this.previewUrl = response.url; - } - - this.isLoading = false; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.css b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.css deleted file mode 100644 index 50c5addee..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.field-value { - padding: 2px 6px; - border-radius: 4px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.html b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.html deleted file mode 100644 index 6a53e0703..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{displayValue}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts deleted file mode 100644 index 295e9c41a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.spec.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SelectDisplayComponent } from './select.component'; - -describe('SelectDisplayComponent', () => { - let component: SelectDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SelectDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(SelectDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display option label from widget params', () => { - fixture.componentRef.setInput('value', 'opt1'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [ - { value: 'opt1', label: 'Option One' }, - { value: 'opt2', label: 'Option Two' }, - ], - }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('Option One'); - }); - - it('should display raw value when no options match', () => { - fixture.componentRef.setInput('value', 'unknown'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [{ value: 'opt1', label: 'Option One' }], - }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('unknown'); - }); - - it('should display option label when value is 0', () => { - fixture.componentRef.setInput('value', 0); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [ - { value: 0, label: 'Zero' }, - { value: 1, label: 'One' }, - ], - }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('Zero'); - }); - - it('should display em dash when value is null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('—'); - }); - - it('should display em dash when value is undefined', () => { - fixture.componentRef.setInput('value', undefined); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe('—'); - }); - - it('should display empty string value as raw, not dash', () => { - fixture.componentRef.setInput('value', ''); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { - options: [{ value: 'opt1', label: 'Option One' }], - }, - }); - component.ngOnInit(); - fixture.detectChanges(); - expect(component.displayValue).toBe(''); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.stories.ts deleted file mode 100644 index 10b2b31a8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.stories.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { SelectDisplayComponent } from './select.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'enum', - data_type_params: ['option1', 'option2', 'option3'], - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Select', - component: SelectDisplayComponent, - args: { - key: 'field_name', - value: 'option1', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'option1' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts b/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts deleted file mode 100644 index 4e3a14fb5..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/select/select.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-select-display', - templateUrl: './select.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './select.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class SelectDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - public displayValue: string; - public backgroundColor: string; - - ngOnInit(): void { - this.setDisplayValue(); - } - - private setDisplayValue(): void { - if (this.value() == null) { - this.displayValue = '—'; - return; - } - - if (this.widgetStructure()?.widget_params?.options) { - // Find the matching option based on value and use its label - const option = this.widgetStructure().widget_params.options.find( - (opt: { value: any; label: string }) => opt.value === this.value(), - ); - this.displayValue = option ? option.label : this.value(); - this.backgroundColor = option?.background_color ? option.background_color : 'transparent'; - } else if (this.structure()?.data_type_params) { - // If no widget structure but we have data_type_params, just use the value - this.displayValue = this.value(); - } else { - this.displayValue = this.value(); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.css b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html deleted file mode 100644 index 479ddf07a..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{value() || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.spec.ts deleted file mode 100644 index cd02fd957..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StaticTextDisplayComponent } from './static-text.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('StaticTextDisplayComponent', () => { - let component: StaticTextDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StaticTextDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(StaticTextDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 'Hello World'); - fixture.detectChanges(); - expect(component.value()).toBe('Hello World'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - expect(component.value()).toBeNull(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.stories.ts deleted file mode 100644 index c66a2bd3d..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { StaticTextDisplayComponent } from './static-text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/StaticText', - component: StaticTextDisplayComponent, - args: { - key: 'field_name', - value: 'Static content', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Static content' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.ts b/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.ts deleted file mode 100644 index 483c6c326..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/static-text/static-text.component.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; - -@Component({ - selector: 'app-display-static-text', - templateUrl: './static-text.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './static-text.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule], -}) -export class StaticTextDisplayComponent extends BaseTableDisplayFieldComponent { - static type = 'static-text'; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.css b/frontend/src/app/components/ui-components/table-display-fields/text/text.component.css deleted file mode 100644 index 327f9c253..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.css +++ /dev/null @@ -1,6 +0,0 @@ -.validation-error { - color: var(--color-warnPalette-500); - text-decoration: underline; - text-decoration-style: wavy; - text-underline-offset: 2px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.html b/frontend/src/app/components/ui-components/table-display-fields/text/text.component.html deleted file mode 100644 index fe63b0835..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{value() || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/text/text.component.spec.ts deleted file mode 100644 index 18573e992..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TextDisplayComponent } from './text.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('TextDisplayComponent', () => { - let component: TextDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TextDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display value', () => { - fixture.componentRef.setInput('value', 'Sample text'); - fixture.detectChanges(); - expect(component.value()).toBe('Sample text'); - }); - - it('should detect invalid email when email validation configured', () => { - fixture.componentRef.setInput('value', 'not-an-email'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { validate: 'isEmail' }, - }); - fixture.detectChanges(); - expect(component.isInvalid).toBe(true); - }); - - it('should report valid for correct email', () => { - fixture.componentRef.setInput('value', 'user@example.com'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { validate: 'isEmail' }, - }); - fixture.detectChanges(); - expect(component.isInvalid).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/text/text.component.stories.ts deleted file mode 100644 index 4a36e414f..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TextDisplayComponent } from './text.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Text', - component: TextDisplayComponent, - args: { - key: 'field_name', - value: 'Sample text', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'Sample text' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.ts b/frontend/src/app/components/ui-components/table-display-fields/text/text.component.ts deleted file mode 100644 index 000cbc7e0..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/text/text.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Component } from '@angular/core'; -import * as validator from 'validator'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-text', - templateUrl: './text.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './text.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class TextDisplayComponent extends BaseTableDisplayFieldComponent { - get isInvalid(): boolean { - if (!this.value() || this.value() === '') { - return false; - } - - const validateType = this.widgetStructure()?.widget_params?.validate; - if (!validateType) { - return false; - } - - const stringValue = String(this.value()); - - // Special case for regex validation - if (validateType === 'regex') { - const regexPattern = this.widgetStructure()?.widget_params?.regex; - if (!regexPattern) { - return false; - } - try { - const regex = new RegExp(regexPattern); - return !regex.test(stringValue); - } catch (error) { - console.warn('Invalid regex pattern:', error); - return false; - } - } - - // Check if validator has this method - // biome-ignore lint: it is expected to import all exports of validator - const validatorMethod = validator[validateType]; - if (typeof validatorMethod !== 'function') { - console.warn(`Unknown validator method: ${validateType}`); - return false; - } - - try { - // Call the validator method and invert result (true if invalid) - return !validatorMethod(stringValue); - } catch (error) { - console.warn(`Validation error for ${validateType}:`, error); - return false; - } - } - - get validationErrorMessage(): string { - const validateType = this.widgetStructure()?.widget_params?.validate; - if (!validateType) { - return ''; - } - - if (validateType === 'regex') { - return 'Does not match the required pattern'; - } - - // Create user-friendly messages for common validators - const messages = { - isEmail: 'Invalid email address', - isURL: 'Invalid URL', - isIP: 'Invalid IP address', - isUUID: 'Invalid UUID', - isJSON: 'Invalid JSON', - isCreditCard: 'Invalid credit card number', - isISBN: 'Invalid ISBN', - isAlpha: 'Should contain only letters', - isNumeric: 'Should contain only numbers', - isAlphanumeric: 'Should contain only letters and numbers', - isHexColor: 'Invalid hex color', - isBase64: 'Invalid Base64 string', - isMobilePhone: 'Invalid mobile phone number', - isPostalCode: 'Invalid postal code' - }; - - return messages[validateType] || `Invalid ${validateType.replace(/^is/, '').toLowerCase()}`; - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.css b/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.html b/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.html deleted file mode 100644 index 7a7b4e451..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{ formattedInterval }} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.spec.ts deleted file mode 100644 index 2aef98197..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimeIntervalDisplayComponent } from './time-interval.component'; - -describe('TimeIntervalDisplayComponent', () => { - let component: TimeIntervalDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeIntervalDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeIntervalDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format interval object', () => { - fixture.componentRef.setInput('value', { days: 2, hours: 5, minutes: 30 }); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedInterval).toBe('2d 5h 30m'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedInterval).toBe('\u2014'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.stories.ts deleted file mode 100644 index 5d31102b6..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeIntervalDisplayComponent } from './time-interval.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'interval', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/TimeInterval', - component: TimeIntervalDisplayComponent, - args: { - key: 'field_name', - value: '01:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '01:30:00' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.ts b/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.ts deleted file mode 100644 index d2c2146a8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time-interval/time-interval.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-time-interval-display', - templateUrl: './time-interval.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './time-interval.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class TimeIntervalDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - - formattedInterval: string; - - ngOnInit() { - if (!this.value()) { - this.formattedInterval = '—'; - return; - } - - try { - const interval = typeof this.value() === 'string' ? JSON.parse(this.value()) : this.value(); - let parts = []; - - if (interval.days) parts.push(`${interval.days}d`); - if (interval.hours) parts.push(`${interval.hours}h`); - if (interval.minutes) parts.push(`${interval.minutes}m`); - if (interval.seconds) parts.push(`${interval.seconds}s`); - if (interval.milliseconds) parts.push(`${interval.milliseconds}ms`); - - this.formattedInterval = parts.length > 0 ? parts.join(' ') : '0'; - } catch (_e) { - this.formattedInterval = String(this.value()); - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.css b/frontend/src/app/components/ui-components/table-display-fields/time/time.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.html b/frontend/src/app/components/ui-components/table-display-fields/time/time.component.html deleted file mode 100644 index 6a8efae37..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{formattedTime || '—'}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/time/time.component.spec.ts deleted file mode 100644 index 752a6f506..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimeDisplayComponent } from './time.component'; - -describe('TimeDisplayComponent', () => { - let component: TimeDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimeDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimeDisplayComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should format time string', () => { - fixture.componentRef.setInput('value', '14:30:00'); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedTime).toBe('14:30:00'); - }); - - it('should display dash for null', () => { - fixture.componentRef.setInput('value', null); - component.ngOnInit(); - fixture.detectChanges(); - - expect(component.formattedTime).toBeUndefined(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/time/time.component.stories.ts deleted file mode 100644 index 3104fc7c5..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimeDisplayComponent } from './time.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'time', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Time', - component: TimeDisplayComponent, - args: { - key: 'field_name', - value: '10:30:00', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '10:30:00' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.ts b/frontend/src/app/components/ui-components/table-display-fields/time/time.component.ts deleted file mode 100644 index be427ecda..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/time/time.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { format } from 'date-fns'; - -@Component({ - selector: 'app-time-display', - templateUrl: './time.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './time.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class TimeDisplayComponent extends BaseTableDisplayFieldComponent implements OnInit { - static type = 'time'; - - public formattedTime: string; - - ngOnInit(): void { - if (this.value()) { - try { - if (this.value().includes(':')) { - // Handle time string format - this.formattedTime = this.value(); - } else { - const date = new Date(this.value()); - if (!Number.isNaN(date.getTime())) { - this.formattedTime = format(date, 'HH:mm:ss'); - } else { - this.formattedTime = this.value(); - } - } - } catch (_error) { - this.formattedTime = this.value(); - } - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css deleted file mode 100644 index 99aa281f0..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.css +++ /dev/null @@ -1 +0,0 @@ -/* Timezone specific styles */ diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html deleted file mode 100644 index 1cc2467f7..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- {{formattedTimezone}} - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts deleted file mode 100644 index 490009280..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TimezoneDisplayComponent } from './timezone.component'; - -describe('TimezoneDisplayComponent', () => { - let component: TimezoneDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TimezoneDisplayComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TimezoneDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display formatted timezone with UTC offset', () => { - fixture.componentRef.setInput('value', 'America/New_York'); - expect(component.formattedTimezone).toContain('America/New_York'); - expect(component.formattedTimezone).toContain('UTC'); - }); - - it('should display dash for null value', () => { - fixture.componentRef.setInput('value', null); - expect(component.formattedTimezone).toBe('—'); - }); - - it('should render copy button', () => { - fixture.componentRef.setInput('value', 'Europe/London'); - fixture.detectChanges(); - const compiled = fixture.nativeElement; - const button = compiled.querySelector('button'); - expect(button).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.stories.ts deleted file mode 100644 index 524bc7927..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TimezoneDisplayComponent } from './timezone.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/Timezone', - component: TimezoneDisplayComponent, - args: { - key: 'field_name', - value: 'UTC', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'UTC' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts b/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts deleted file mode 100644 index 3269735c0..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/timezone/timezone.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-timezone', - templateUrl: './timezone.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './timezone.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class TimezoneDisplayComponent extends BaseTableDisplayFieldComponent { - get formattedTimezone(): string { - if (!this.value()) { - return '—'; - } - - try { - const offset = this.getTimezoneOffset(this.value()); - return `${this.value()} (UTC${offset})`; - } catch (_error) { - return this.value(); - } - } - - private getTimezoneOffset(timezone: string): string { - try { - const now = new Date(); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone: timezone, - timeZoneName: 'longOffset' - }); - - const parts = formatter.formatToParts(now); - const offsetPart = parts.find(part => part.type === 'timeZoneName'); - - if (offsetPart?.value.includes('GMT')) { - const offset = offsetPart.value.replace('GMT', ''); - return offset === '' ? '+00:00' : offset; - } - - // Fallback: calculate offset manually - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: timezone })); - const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / 60000; - const hours = Math.floor(Math.abs(offsetMinutes) / 60); - const minutes = Math.abs(offsetMinutes) % 60; - const sign = offsetMinutes >= 0 ? '+' : '-'; - return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; - } catch (_error) { - return ''; - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.css b/frontend/src/app/components/ui-components/table-display-fields/url/url.component.css deleted file mode 100644 index d4a57897b..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.css +++ /dev/null @@ -1,14 +0,0 @@ -.url-link { - display: flex; - align-items: center; - gap: 4px; - color: var(--color-primaryPalette-500); -} - -.url-link__icon { - flex-shrink: 0; - margin-bottom: -2px; - width: 16px; - height: 16px; - font-size: 16px; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.html b/frontend/src/app/components/ui-components/table-display-fields/url/url.component.html deleted file mode 100644 index 8f2d0cc36..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.html +++ /dev/null @@ -1,22 +0,0 @@ -
- - @if (isValidUrl) { - - link - {{value() || '—'}} - - } @else { - {{value() || '—'}} - } - - -
diff --git a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/url/url.component.spec.ts deleted file mode 100644 index e747d14da..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CommonModule } from '@angular/common'; -import { UrlDisplayComponent } from './url.component'; - -describe('UrlDisplayComponent', () => { - let component: UrlDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UrlDisplayComponent], - }) - .overrideComponent(UrlDisplayComponent, { - add: { imports: [CommonModule] }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UrlDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should return valid URL', () => { - fixture.componentRef.setInput('value', 'https://example.com'); - expect(component.isValidUrl).toBe(true); - expect(component.hrefValue).toBe('https://example.com'); - }); - - it('should prepend prefix from widget params', () => { - fixture.componentRef.setInput('value', 'example.com/path'); - fixture.componentRef.setInput('widgetStructure', { - widget_params: { prefix: 'https://' }, - }); - expect(component.hrefValue).toBe('https://example.com/path'); - expect(component.isValidUrl).toBe(true); - }); - - it('should detect invalid URL', () => { - fixture.componentRef.setInput('value', 'not a valid url'); - expect(component.isValidUrl).toBe(false); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/url/url.component.stories.ts deleted file mode 100644 index 77cca0652..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UrlDisplayComponent } from './url.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/URL', - component: UrlDisplayComponent, - args: { - key: 'field_name', - value: 'https://example.com', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: 'https://example.com' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.ts b/frontend/src/app/components/ui-components/table-display-fields/url/url.component.ts deleted file mode 100644 index a5fc0cdf8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/url/url.component.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { Component } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-url-display', - templateUrl: './url.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './url.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class UrlDisplayComponent extends BaseTableDisplayFieldComponent { - static type = 'url'; - - get hrefValue(): string { - if (!this.value()) return ''; - const prefix = this.widgetStructure()?.widget_params?.prefix || ''; - return prefix + this.value(); - } - - get isValidUrl(): boolean { - if (!this.value()) return false; - try { - // Check if the prefixed URL is valid - new URL(this.hrefValue); - return true; - } catch { - return false; - } - } -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.css b/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.css deleted file mode 100644 index 6cd942ea5..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.css +++ /dev/null @@ -1,37 +0,0 @@ -.uuid-display { - display: flex; - align-items: center; - gap: 4px; -} - -.uuid-text { - font-family: "Roboto Condensed", "Arial Narrow", sans-serif; - font-stretch: condensed; -} - -.copy-button { - width: 24px; - height: 24px; - line-height: 24px; - opacity: 0; - transition: opacity 0.2s; -} - -.field-value:hover .copy-button { - opacity: 0.7; -} - -.copy-button:hover { - opacity: 1 !important; -} - -.copy-button mat-icon { - font-size: 16px; - width: 16px; - height: 16px; -} - -.null-value { - color: var(--mat-grey-500); - font-style: italic; -} diff --git a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.html b/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.html deleted file mode 100644 index 3647e9df9..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.html +++ /dev/null @@ -1,16 +0,0 @@ -
- @if (value()) { - {{ value() }} - - } - @else { - NULL - } -
\ No newline at end of file diff --git a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.spec.ts b/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.spec.ts deleted file mode 100644 index 0b1852ac4..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { UuidDisplayComponent } from './uuid.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -describe('UuidDisplayComponent', () => { - let component: UuidDisplayComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UuidDisplayComponent, BrowserAnimationsModule], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UuidDisplayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display UUID value', () => { - fixture.componentRef.setInput('value', '550e8400-e29b-41d4-a716-446655440000'); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.uuid-text')?.textContent?.trim()).toBe('550e8400-e29b-41d4-a716-446655440000'); - }); - - it('should show NULL for empty value', () => { - fixture.componentRef.setInput('value', null); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.null-value')?.textContent?.trim()).toBe('NULL'); - }); -}); diff --git a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.stories.ts b/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.stories.ts deleted file mode 100644 index 3cd230e00..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.stories.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UuidDisplayComponent } from './uuid.component'; - -const mockStructure = { - column_name: 'field_name', - column_default: null, - data_type: 'uuid', - isExcluded: false, - isSearched: false, - allow_null: true, - auto_increment: false, - character_maximum_length: 255, -}; - -const mockWidgetStructure = { - field_name: 'field_name', - widget_type: 'Default', - widget_params: {}, - name: '', - description: '', -}; - -const meta: Meta = { - title: 'Table Display Fields/UUID', - component: UuidDisplayComponent, - args: { - key: 'field_name', - value: '550e8400-e29b-41d4-a716-446655440000', - structure: mockStructure as any, - widgetStructure: mockWidgetStructure as any, - rowData: { field_name: '550e8400-e29b-41d4-a716-446655440000' }, - primaryKeys: [{ column_name: 'id' }], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - args: { value: null }, -}; diff --git a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.ts b/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.ts deleted file mode 100644 index 92b22f1c8..000000000 --- a/frontend/src/app/components/ui-components/table-display-fields/uuid/uuid.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component } from '@angular/core'; -import { BaseTableDisplayFieldComponent } from '../base-table-display-field/base-table-display-field.component'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; - -@Component({ - selector: 'app-display-uuid', - templateUrl: './uuid.component.html', - styleUrls: ['../base-table-display-field/base-table-display-field.component.css', './uuid.component.css'], - imports: [ClipboardModule, MatIconModule, MatButtonModule, MatTooltipModule] -}) -export class UuidDisplayComponent extends BaseTableDisplayFieldComponent { -} \ No newline at end of file diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.css b/frontend/src/app/components/ui-components/turnstile/turnstile.component.css deleted file mode 100644 index 46f2ea33b..000000000 --- a/frontend/src/app/components/ui-components/turnstile/turnstile.component.css +++ /dev/null @@ -1,5 +0,0 @@ -.turnstile-container { - display: flex; - justify-content: center; - margin: 16px 0; -} diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.html b/frontend/src/app/components/ui-components/turnstile/turnstile.component.html deleted file mode 100644 index 515fd4bd4..000000000 --- a/frontend/src/app/components/ui-components/turnstile/turnstile.component.html +++ /dev/null @@ -1 +0,0 @@ -
diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts b/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts deleted file mode 100644 index 4302368bb..000000000 --- a/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TurnstileComponent } from './turnstile.component'; - -describe('TurnstileComponent', () => { - let component: TurnstileComponent; - let fixture: ComponentFixture; - let mockWidgetId: string; - - const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - - beforeEach(async () => { - mockWidgetId = 'mock-widget-id'; - - (window as any).turnstile = { - render: vi.fn().mockReturnValue(mockWidgetId), - reset: vi.fn(), - getResponse: vi.fn(), - remove: vi.fn(), - }; - - await TestBed.configureTestingModule({ - imports: [TurnstileComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TurnstileComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - component.ngOnDestroy(); - delete (window as any).turnstile; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should render turnstile widget on init', async () => { - fixture.detectChanges(); - await delay(150); - - expect((window as any).turnstile?.render).toHaveBeenCalled(); - }); - - it('should emit tokenReceived when callback is triggered', async () => { - let receivedToken: string | null = null; - component.tokenReceived.subscribe((token: string) => { - receivedToken = token; - }); - - ((window as any).turnstile?.render as ReturnType).mockImplementation( - (_container: any, options: any) => { - options.callback('test-token'); - return mockWidgetId; - }, - ); - - fixture.detectChanges(); - await delay(150); - - expect(receivedToken).toBe('test-token'); - }); - - it('should emit tokenError when error-callback is triggered', async () => { - let errorEmitted = false; - component.tokenError.subscribe(() => { - errorEmitted = true; - }); - - ((window as any).turnstile?.render as ReturnType).mockImplementation( - (_container: any, options: any) => { - options['error-callback'](); - return mockWidgetId; - }, - ); - - fixture.detectChanges(); - await delay(150); - - expect(errorEmitted).toBe(true); - }); - - it('should emit tokenExpired when expired-callback is triggered', async () => { - let expiredEmitted = false; - component.tokenExpired.subscribe(() => { - expiredEmitted = true; - }); - - ((window as any).turnstile?.render as ReturnType).mockImplementation( - (_container: any, options: any) => { - options['expired-callback'](); - return mockWidgetId; - }, - ); - - fixture.detectChanges(); - await delay(150); - - expect(expiredEmitted).toBe(true); - }); - - it('should reset the widget when reset() is called', async () => { - fixture.detectChanges(); - await delay(150); - - component.reset(); - - expect((window as any).turnstile?.reset).toHaveBeenCalledWith(mockWidgetId); - }); - - it('should remove widget on destroy', async () => { - fixture.detectChanges(); - await delay(150); - - component.ngOnDestroy(); - - expect((window as any).turnstile?.remove).toHaveBeenCalledWith(mockWidgetId); - }); - - it('should emit error if turnstile fails to load', async () => { - delete (window as any).turnstile; - let errorEmitted = false; - component.tokenError.subscribe(() => { - errorEmitted = true; - }); - - fixture.detectChanges(); - - // Poll until the error is emitted instead of relying on a fixed delay, - // since zone.js and browser overhead make timers unreliable. - const maxWait = 12000; - const pollInterval = 200; - let waited = 0; - while (!errorEmitted && waited < maxWait) { - await delay(pollInterval); - waited += pollInterval; - } - - expect(errorEmitted).toBe(true); - }, 20000); -}); diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts b/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts deleted file mode 100644 index 73ff0cbef..000000000 --- a/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; -import { environment } from 'src/environments/environment'; - -@Component({ - selector: 'app-turnstile', - templateUrl: './turnstile.component.html', - styleUrls: ['./turnstile.component.css'], - imports: [CommonModule], -}) -export class TurnstileComponent implements OnInit, OnDestroy { - @ViewChild('turnstileContainer', { static: true }) turnstileContainer: ElementRef; - - @Input() siteKey: string = (environment as any).turnstileSiteKey; - @Input() theme: 'light' | 'dark' | 'auto' = 'auto'; - - @Output() tokenReceived = new EventEmitter(); - @Output() tokenError = new EventEmitter(); - @Output() tokenExpired = new EventEmitter(); - - private widgetId: string | null = null; - private pollInterval: ReturnType | null = null; - private readonly MAX_POLL_ATTEMPTS = 50; - private readonly POLL_INTERVAL_MS = 100; - - ngOnInit(): void { - this._waitForTurnstileAndRender(); - } - - ngOnDestroy(): void { - this._clearPollInterval(); - this._removeWidget(); - } - - public reset(): void { - if (window.turnstile && this.widgetId) { - window.turnstile.reset(this.widgetId); - } - } - - private _waitForTurnstileAndRender(): void { - let attempts = 0; - - this.pollInterval = setInterval(() => { - attempts++; - - if (window.turnstile) { - this._clearPollInterval(); - this._renderWidget(); - return; - } - - if (attempts >= this.MAX_POLL_ATTEMPTS) { - this._clearPollInterval(); - console.error('Turnstile script failed to load'); - this.tokenError.emit(); - } - }, this.POLL_INTERVAL_MS); - } - - private _renderWidget(): void { - if (!window.turnstile || !this.turnstileContainer?.nativeElement) { - return; - } - - this.widgetId = window.turnstile.render(this.turnstileContainer.nativeElement, { - sitekey: this.siteKey, - callback: (token: string) => { - this.tokenReceived.emit(token); - }, - 'error-callback': () => { - this.tokenError.emit(); - }, - 'expired-callback': () => { - this.tokenExpired.emit(); - }, - theme: this.theme, - appearance: 'always', - }); - } - - private _removeWidget(): void { - if (window.turnstile && this.widgetId) { - window.turnstile.remove(this.widgetId); - this.widgetId = null; - } - } - - private _clearPollInterval(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } - } -} diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.stories.ts b/frontend/src/app/components/ui-components/turnstile/turnstile.stories.ts deleted file mode 100644 index 4b9ecef94..000000000 --- a/frontend/src/app/components/ui-components/turnstile/turnstile.stories.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { TurnstileComponent } from './turnstile.component'; - -const meta: Meta = { - title: 'UI/Turnstile', - component: TurnstileComponent, - args: { - siteKey: '1x00000000000000000000AA', - theme: 'auto', - }, - argTypes: { - theme: { - control: 'select', - options: ['light', 'dark', 'auto'], - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Light: Story = { - args: { theme: 'light' }, -}; - -export const Dark: Story = { - args: { theme: 'dark' }, -}; diff --git a/frontend/src/app/components/ui-components/user-password/user-password.component.css b/frontend/src/app/components/ui-components/user-password/user-password.component.css deleted file mode 100644 index c03808c4a..000000000 --- a/frontend/src/app/components/ui-components/user-password/user-password.component.css +++ /dev/null @@ -1,23 +0,0 @@ -.password-field { - margin-bottom: -16px; - width: 100%; -} - -.password-requirements { - list-style: none; - columns: 2; - gap: 60px; - margin-top: 2px; -} - -.password-requirements li::before { - content: "—"; -} - -.error { - color: var(--color-warnPalette-500); -} - -.success { - color: var(--color-successPalette-500); -} diff --git a/frontend/src/app/components/ui-components/user-password/user-password.component.html b/frontend/src/app/components/ui-components/user-password/user-password.component.html deleted file mode 100644 index 9a2550ee9..000000000 --- a/frontend/src/app/components/ui-components/user-password/user-password.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - {{ label }} - - - {{passwordHidden ? 'visibility_off' : 'visibility'}} - - - - - - diff --git a/frontend/src/app/components/ui-components/user-password/user-password.component.spec.ts b/frontend/src/app/components/ui-components/user-password/user-password.component.spec.ts deleted file mode 100644 index 856b23357..000000000 --- a/frontend/src/app/components/ui-components/user-password/user-password.component.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; -import { Angulartics2Module } from 'angulartics2'; -import { UserPasswordComponent } from './user-password.component'; - -describe('UserPasswordComponent', () => { - let component: UserPasswordComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [FormsModule, BrowserAnimationsModule, Angulartics2Module.forRoot({}), UserPasswordComponent], - providers: [{ provide: IPasswordStrengthMeterService, useValue: {} }], - }).compileComponents(); - - fixture = TestBed.createComponent(UserPasswordComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/ui-components/user-password/user-password.component.ts b/frontend/src/app/components/ui-components/user-password/user-password.component.ts deleted file mode 100644 index 2cf6d7709..000000000 --- a/frontend/src/app/components/ui-components/user-password/user-password.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { PasswordStrengthMeterComponent } from 'angular-password-strength-meter'; -import { Angulartics2OnModule } from 'angulartics2'; -import posthog from 'posthog-js'; -import { PasswordValidationDirective } from 'src/app/directives/passwordValidator.directive'; - -@Component({ - selector: 'app-user-password', - templateUrl: './user-password.component.html', - styleUrls: ['./user-password.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatIconModule, - PasswordValidationDirective, - PasswordStrengthMeterComponent, - Angulartics2OnModule, - ], -}) -export class UserPasswordComponent implements OnInit { - protected posthog = posthog; - - @Input() value: string; - @Input() label: string; - @Output() onFieldChange = new EventEmitter(); - - public passwordHidden: boolean; - - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/ui-components/user-password/user-password.stories.ts b/frontend/src/app/components/ui-components/user-password/user-password.stories.ts deleted file mode 100644 index f5d579d42..000000000 --- a/frontend/src/app/components/ui-components/user-password/user-password.stories.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { UserPasswordComponent } from './user-password.component'; - -const meta: Meta = { - title: 'UI/UserPassword', - component: UserPasswordComponent, - args: { - value: '', - label: 'Password', - }, -}; - -export default meta; -type Story = StoryObj; - -export const Empty: Story = {}; - -export const WithValue: Story = { - args: { value: 'MyStr0ngP@ss!' }, -}; - -export const CustomLabel: Story = { - args: { label: 'Confirm Password', value: '' }, -}; diff --git a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.css b/frontend/src/app/components/user-deleted-success/user-deleted-success.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.html b/frontend/src/app/components/user-deleted-success/user-deleted-success.component.html deleted file mode 100644 index bff87f5cb..000000000 --- a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.html +++ /dev/null @@ -1,7 +0,0 @@ - -

- Your account has been deleted successfully. -

-
- Visit home page -
diff --git a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.spec.ts b/frontend/src/app/components/user-deleted-success/user-deleted-success.component.spec.ts deleted file mode 100644 index 44098bcb6..000000000 --- a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UserDeletedSuccessComponent } from './user-deleted-success.component'; - -describe('UserDeletedSuccessComponent', () => { - let component: UserDeletedSuccessComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UserDeletedSuccessComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(UserDeletedSuccessComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.ts b/frontend/src/app/components/user-deleted-success/user-deleted-success.component.ts deleted file mode 100644 index 678a2449d..000000000 --- a/frontend/src/app/components/user-deleted-success/user-deleted-success.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { BannerComponent } from '../ui-components/banner/banner.component'; - -@Component({ - selector: 'app-user-deleted-success', - imports: [CommonModule, BannerComponent], - templateUrl: './user-deleted-success.component.html', - styleUrls: ['./user-deleted-success.component.css'], -}) -export class UserDeletedSuccessComponent implements OnInit { - ngOnInit(): void {} -} diff --git a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.css b/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.html b/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.html deleted file mode 100644 index c9757dc3e..000000000 --- a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.html +++ /dev/null @@ -1,14 +0,0 @@ -

Are you sure?

- -

- This action is permanent and irreversible, you will not be able to restore your account in the future -

-
- - - - diff --git a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.spec.ts b/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.spec.ts deleted file mode 100644 index 0c2c01605..000000000 --- a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { AccountDeleteConfirmationComponent } from './account-delete-confirmation.component'; - -describe('AccountDeleteConfirmationComponent', () => { - let component: AccountDeleteConfirmationComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatDialogModule, MatSnackBarModule, Angulartics2Module.forRoot(), AccountDeleteConfirmationComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AccountDeleteConfirmationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.ts b/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.ts deleted file mode 100644 index f83a402fe..000000000 --- a/frontend/src/app/components/user-settings/account-delete-confirmation/account-delete-confirmation.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { Router } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { UserService } from 'src/app/services/user.service'; - -@Component({ - selector: 'app-account-delete-confirmation', - imports: [CommonModule, MatDialogModule, MatButtonModule, MatProgressSpinnerModule], - templateUrl: './account-delete-confirmation.component.html', - styleUrls: ['./account-delete-confirmation.component.css'], -}) -export class AccountDeleteConfirmationComponent implements OnInit { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _userService: UserService, - public dialogRef: MatDialogRef, - public router: Router, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void {} - - deleteAccount() { - this.submitting = true; - this._userService.deleteAccount(this.data).subscribe( - () => { - this.angulartics2.eventTrack.next({ - action: 'Delete account', - properties: { - email: this.data.email, - reason: this.data.reason, - message: this.data.message, - }, - }); - posthog.capture('Delete account', { - email: this.data.email, - reason: this.data.reason, - message: this.data.message, - }); - this.submitting = false; - this.dialogRef.close(); - }, - undefined, - () => (this.submitting = false), - ); - } -} diff --git a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.css b/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.css deleted file mode 100644 index 5f6d2d4b8..000000000 --- a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.css +++ /dev/null @@ -1,20 +0,0 @@ -.radio-group { - display: flex; - flex-direction: column; -} - -.radio-group__message { - margin-bottom: 8px; -} - -.radio-button { - margin: 8px 0; -} - -.message { - width: 100%; -} - -.message textarea { - resize: none; -} diff --git a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.html b/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.html deleted file mode 100644 index 6a2fe7bff..000000000 --- a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.html +++ /dev/null @@ -1,35 +0,0 @@ -

Delete {{ userEmail }} account

-
- -

- Tell us why are you deleting your account. -

-
- -
- - {{ reasonItem.caption }} - -

-

-
-
- - Anything you want to share? (optional) - - -
- - - - -
diff --git a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.spec.ts b/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.spec.ts deleted file mode 100644 index 569bfbb76..000000000 --- a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { AccountDeleteConfirmationComponent } from '../account-delete-confirmation/account-delete-confirmation.component'; -import { AccountDeleteDialogComponent } from './account-delete-dialog.component'; - -describe('AccountDeleteDialogComponent', () => { - let component: AccountDeleteDialogComponent; - let fixture: ComponentFixture; - let mockMatDialog: { open: ReturnType }; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - mockMatDialog = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatRadioModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - AccountDeleteDialogComponent, - ], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }) - .overrideComponent(AccountDeleteDialogComponent, { - set: { - providers: [{ provide: MatDialog, useFactory: () => mockMatDialog }], - }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(AccountDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should open dialog for delete account confirmation', () => { - component.reason = 'technical-issues'; - component.message = 'I cannot add connection'; - - component.openDeleteConfirmation(); - - expect(mockMatDialog.open).toHaveBeenCalledWith(AccountDeleteConfirmationComponent, { - width: '20em', - data: { - reason: 'technical-issues', - message: 'I cannot add connection', - }, - }); - }); -}); diff --git a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.ts b/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.ts deleted file mode 100644 index f53383956..000000000 --- a/frontend/src/app/components/user-settings/account-delete-dialog/account-delete-dialog.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatRadioModule } from '@angular/material/radio'; -import { AccountDeleteConfirmationComponent } from '../account-delete-confirmation/account-delete-confirmation.component'; - -@Component({ - selector: 'app-account-delete-dialog', - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatRadioModule, - ], - templateUrl: './account-delete-dialog.component.html', - styleUrls: ['./account-delete-dialog.component.css'], -}) -export class AccountDeleteDialogComponent implements OnInit { - public userEmail: string; - public reason: string; - public message: string; - public reasonsList = [ - { - id: 'missing-features', - caption: 'Missing features I need', - message: - 'Perhaps Rocketadmin has updated the functions you need and they are in a different place. ' + - 'Our Support team will advise you.', - }, - { - id: 'technical-issues', - caption: 'Technical issues', - message: - 'If you have technical problems, please contact our Support team and we will solve your problem.', - }, - { - id: 'another-product', - caption: 'Another product', - message: - 'If you are interested in another product, you can read the article where they are compared.', - }, - { - id: 'privacy-concern', - caption: 'I have privacy concern', - message: - 'If you have any doubts about the security of your data, please contact our Support team.', - }, - { - id: 'other', - caption: 'Other', - }, - ]; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - public dialog: MatDialog, - public dialogRef: MatDialogRef, - ) {} - - ngOnInit(): void { - this.userEmail = this.data.email; - } - - openDeleteConfirmation() { - const metadata = { - reason: this.reason, - message: this.message, - }; - - this.dialog.open(AccountDeleteConfirmationComponent, { - width: '20em', - data: metadata, - }); - - this.dialogRef.close(); - } -} diff --git a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.css b/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.html b/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.html deleted file mode 100644 index b0d181d70..000000000 --- a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.html +++ /dev/null @@ -1,14 +0,0 @@ -

Confirm API key delete

- -

You are going to delete {{ data.title }} API key.

-
-

Please confirm.

-
- - - - \ No newline at end of file diff --git a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.spec.ts b/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.spec.ts deleted file mode 100644 index e744ff7c7..000000000 --- a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { ApiKeyDeleteDialogComponent } from './api-key-delete-dialog.component'; - -describe('ApiKeyDeleteDialogComponent', () => { - let component: ApiKeyDeleteDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatDialogModule, ApiKeyDeleteDialogComponent], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ApiKeyDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.ts b/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.ts deleted file mode 100644 index 791329d4b..000000000 --- a/frontend/src/app/components/user-settings/api-key-delete-dialog/api-key-delete-dialog.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { ApiKey } from 'src/app/models/user'; -import { UserService } from 'src/app/services/user.service'; - -@Component({ - selector: 'app-api-key-delete-dialog', - templateUrl: './api-key-delete-dialog.component.html', - styleUrl: './api-key-delete-dialog.component.css', - imports: [CommonModule, MatDialogModule, MatButtonModule], -}) -export class ApiKeyDeleteDialogComponent { - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: ApiKey, - private _userService: UserService, - public dialogRef: MatDialogRef, - ) {} - - deleteAPIkey() { - this.submitting = true; - this._userService.deleteAPIkey(this.data).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.css b/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.css deleted file mode 100644 index a32a8def3..000000000 --- a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.css +++ /dev/null @@ -1,4 +0,0 @@ -.authenticator-code { - margin-top: 12px; - width: 100%; -} diff --git a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.html b/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.html deleted file mode 100644 index c6e35b9cc..000000000 --- a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.html +++ /dev/null @@ -1,23 +0,0 @@ -

Enable 2FA

-
- -

Scan this QR code with 2FA application

- - qr - -
- - Enter the code from your authenticator - - - -
- - - - -
diff --git a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.spec.ts b/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.spec.ts deleted file mode 100644 index 693ab4e72..000000000 --- a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Angulartics2Module } from 'angulartics2'; -import { EnableTwoFADialogComponent } from './enable-two-fa-dialog.component'; - -describe('EnableTwoFADialogComponent', () => { - let component: EnableTwoFADialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: () => {}, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatDialogModule, - Angulartics2Module.forRoot(), - EnableTwoFADialogComponent, - BrowserAnimationsModule, - ], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(EnableTwoFADialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.stories.ts b/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.stories.ts deleted file mode 100644 index 3565846f4..000000000 --- a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.stories.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { of } from 'rxjs'; -import { UserService } from 'src/app/services/user.service'; -import { EnableTwoFADialogComponent } from './enable-two-fa-dialog.component'; - -const mockUserService: Partial = { - confirm2FA: () => of({ validated: true } as any), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/EnableTwoFA', - component: EnableTwoFADialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - }, - { provide: UserService, useValue: mockUserService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.ts b/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.ts deleted file mode 100644 index abf9f0e01..000000000 --- a/frontend/src/app/components/user-settings/enable-two-fa-dialog/enable-two-fa-dialog.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, EventEmitter, Inject, Output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { UserService } from 'src/app/services/user.service'; -import { GroupDeleteDialogComponent } from '../../users/group-delete-dialog/group-delete-dialog.component'; - -@Component({ - selector: 'app-enable-two-fa-dialog', - imports: [FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], - templateUrl: './enable-two-fa-dialog.component.html', - styleUrls: ['./enable-two-fa-dialog.component.css'], -}) -export class EnableTwoFADialogComponent { - @Output() confirm2FAerror = new EventEmitter(); - - public secondFAQRCode: string; - public submitting: boolean = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public qr: any, - public dialogRef: MatDialogRef, - private _userService: UserService, - private angulartics2: Angulartics2, - ) {} - - verify2FA() { - this.submitting = true; - - this._userService.confirm2FA(this.secondFAQRCode).subscribe( - (res) => { - if (res.validated) { - this.secondFAQRCode = ''; - this.submitting = false; - this.dialogRef.close(); - this.angulartics2.eventTrack.next({ - action: 'User settings: 2fa enabled successfully', - }); - posthog.capture('User settings: 2fa enabled successfully'); - } - }, - (err) => { - console.log('error !!!!', err); - this.confirm2FAerror.emit(true); - }, - ); - } -} diff --git a/frontend/src/app/components/user-settings/user-settings.component.css b/frontend/src/app/components/user-settings/user-settings.component.css deleted file mode 100644 index 483571be6..000000000 --- a/frontend/src/app/components/user-settings/user-settings.component.css +++ /dev/null @@ -1,134 +0,0 @@ -.profile-layout { - display: flex; - height: calc(100vh - 64px); -} - -.profile-main { - flex: 1; - background-color: var(--mat-sidenav-content-background-color); - overflow-y: auto; -} - -::ng-deep .profile-main > app-alert { - position: relative; - top: 0; - margin: 24px; -} - -.page { - display: flex; - flex-direction: column; - margin: var(--top-margin) auto; - max-width: 520px; - min-width: 300px; - width: 96%; - padding: 0 24px; -} - -@media (width <= 600px) { - .page { - padding: 0 16px; - margin: 1.5em auto; - } -} - -.user-settings { - margin-top: 32px; -} - -.user-settings__name-form { - display: flex; - align-items: center; - margin-bottom: -24px; -} - -.user-settings__section-heading { - display: flex; - align-items: center; - gap: 12px; - margin-top: 40px; - margin-bottom: 8px; -} - -.user-settings__text-input { - width: 100%; -} - -.user-settings__save-button { - margin-top: -20px; - margin-left: -68px; -} - -.user-settings__value { - margin-bottom: 0 !important; -} - -.twoFA-toggle { - display: flex; - align-items: center; - gap: 12px; - margin-top: 40px; -} - -.twoFA-toggle .twoFAhint { - font-size: 20px; - height: 20px; - width: 20px; -} - -.qr-verification { - margin-top: 28px; -} - -.api-key-form { - display: flex; - align-items: center; -} - -.api-key-form__generate-button { - margin-top: -20px; - margin-left: -82px; -} - -.api-key-value { - display: flex; - margin-bottom: 8px; - width: 100%; -} - -.api-key-value__copy-button { - margin-top: 4px; - margin-left: 12px; -} - -.api-key-list-item:hover { - background: var(--color-primaryPalette-50); -} - -@media (prefers-color-scheme: dark) { - .api-key-list-item:hover { - background: var(--color-primaryPalette-500); - } - - ::ng-deep .user-settings__section-heading mat-icon[svgIcon] svg path { - fill: #ffffff; - } -} - -@media (prefers-color-scheme: light) { - .api-key-list-item:hover { - background: var(--color-primaryPalette-50); - } -} - -.api-key { - display: flex; - align-items: center; - justify-content: space-between; - /* width: 100%; */ -} - -.delete-button { - align-self: center; - margin-top: 80px; -} diff --git a/frontend/src/app/components/user-settings/user-settings.component.html b/frontend/src/app/components/user-settings/user-settings.component.html deleted file mode 100644 index b6762446d..000000000 --- a/frontend/src/app/components/user-settings/user-settings.component.html +++ /dev/null @@ -1,104 +0,0 @@ -
- - -
- - - -
-

- Account settings -

- - - - -
-
-
diff --git a/frontend/src/app/components/user-settings/user-settings.component.spec.ts b/frontend/src/app/components/user-settings/user-settings.component.spec.ts deleted file mode 100644 index b5aa5cb91..000000000 --- a/frontend/src/app/components/user-settings/user-settings.component.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { forwardRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { CompanyMemberRole } from 'src/app/models/company'; -import { SubscriptionPlans } from 'src/app/models/user'; -import { UserService } from 'src/app/services/user.service'; -import { AccountDeleteDialogComponent } from './account-delete-dialog/account-delete-dialog.component'; -import { UserSettingsComponent } from './user-settings.component'; - -describe('UserSettingsComponent', () => { - let component: UserSettingsComponent; - let fixture: ComponentFixture; - let userService: UserService; - let mockMatDialog: { open: ReturnType }; - - beforeEach(async () => { - mockMatDialog = { open: vi.fn() }; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - FormsModule, - MatInputModule, - MatSlideToggleModule, - MatSnackBarModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - UserSettingsComponent, - ], - providers: [ - provideHttpClient(), - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UserSettingsComponent), - multi: true, - }, - ], - }) - .overrideComponent(UserSettingsComponent, { - set: { - providers: [{ provide: MatDialog, useFactory: () => mockMatDialog }], - }, - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UserSettingsComponent); - component = fixture.componentInstance; - userService = TestBed.inject(UserService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should request email change', () => { - const fakeRequestEmailChange = vi - .spyOn(userService, 'requestEmailChange') - .mockReturnValue(of({ message: 'requested' })); - - component.changeEmail(); - expect(fakeRequestEmailChange).toHaveBeenCalled(); - }); - - it('should open delete account dialog', () => { - component.currentUser = { - id: 'user-12345678', - createdAt: '2021-10-01T13:43:02.034Z', - isActive: true, - email: 'user@test.com', - subscriptionLevel: SubscriptionPlans.free, - is_2fa_enabled: false, - role: CompanyMemberRole.Member, - externalRegistrationProvider: null, - company: { - id: 'company_123', - }, - }; - - component.confirmDeleteAccount(); - expect(mockMatDialog.open).toHaveBeenCalledWith(AccountDeleteDialogComponent, { - width: '32em', - data: { - id: 'user-12345678', - createdAt: '2021-10-01T13:43:02.034Z', - isActive: true, - email: 'user@test.com', - subscriptionLevel: SubscriptionPlans.free, - is_2fa_enabled: false, - role: CompanyMemberRole.Member, - externalRegistrationProvider: null, - company: { - id: 'company_123', - }, - }, - }); - }); -}); diff --git a/frontend/src/app/components/user-settings/user-settings.component.ts b/frontend/src/app/components/user-settings/user-settings.component.ts deleted file mode 100644 index 2f6432648..000000000 --- a/frontend/src/app/components/user-settings/user-settings.component.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { Angulartics2, Angulartics2Module, Angulartics2OnModule } from 'angulartics2'; -import posthog from 'posthog-js'; -import { Alert, AlertActionType, AlertType } from 'src/app/models/alert'; -import { User } from 'src/app/models/user'; -import { AuthService } from 'src/app/services/auth.service'; -import { UserService } from 'src/app/services/user.service'; -import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { AccountDeleteDialogComponent } from './account-delete-dialog/account-delete-dialog.component'; -import { EnableTwoFADialogComponent } from './enable-two-fa-dialog/enable-two-fa-dialog.component'; - -@Component({ - selector: 'app-user-settings', - templateUrl: './user-settings.component.html', - styleUrls: ['./user-settings.component.css'], - imports: [ - CommonModule, - FormsModule, - RouterModule, - MatButtonModule, - MatDialogModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatSlideToggleModule, - MatTooltipModule, - Angulartics2Module, - AlertComponent, - Angulartics2OnModule, - ProfileSidebarComponent, - ], -}) -export class UserSettingsComponent implements OnInit { - protected posthog = posthog; - public currentUser: User = null; - public submittingChangedName: boolean; - public userName: string; - public emailVerificationWarning: Alert = { - id: 10000001, - type: AlertType.Warning, - message: 'Your email address is not confirmed. Please check your email or resend confirmation.', - actions: [ - { - type: AlertActionType.Button, - caption: 'Resend confirmation', - action: () => this.requestEmailVerification(), - }, - ], - }; - - public authProviderIcons = { - GOOGLE: 'google', - GITHUB: 'github', - }; - - public authCode: string; - public is2FAswitchingOffSettingsShown: boolean = false; - public is2FAEnabledToggle: boolean; - - constructor( - private _userService: UserService, - private _authService: AuthService, - public dialog: MatDialog, - private title: Title, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.title.setTitle(`Account settings | Rocketadmin`); - this.currentUser = null; - this._userService.cast.subscribe((user) => { - this.currentUser = user; - this.userName = user.name; - this.is2FAEnabledToggle = user.is_2fa_enabled; - }); - } - - requestEmailVerification() { - this._authService.requestEmailVerifications().subscribe(); - } - - changeEmail() { - this._userService.requestEmailChange().subscribe((res) => { - this.angulartics2.eventTrack.next({ - action: 'User settings: email change request is sent successfully', - }); - posthog.capture('User settings: email change request is sent successfully'); - console.log(res); - }); - } - - confirmDeleteAccount() { - // event.preventDefault(); - // event.stopImmediatePropagation(); - this.dialog.open(AccountDeleteDialogComponent, { - width: '32em', - data: this.currentUser, - }); - } - - changeUserName() { - this.submittingChangedName = true; - this._userService.changeUserName(this.userName).subscribe( - (_res) => { - this.submittingChangedName = false; - this.angulartics2.eventTrack.next({ - action: 'User settings: user name is updated successfully', - }); - posthog.capture('User settings: user name is updated successfully'); - }, - () => { - this.submittingChangedName = false; - }, - () => { - this.submittingChangedName = false; - }, - ); - } - - switch2FA(event) { - if (event.checked && !this.currentUser.is_2fa_enabled) { - this._userService.switchOn2FA().subscribe((res) => { - const enableTwoFADialog = this.dialog.open(EnableTwoFADialogComponent, { - width: '32em', - data: res.qrCode, - }); - - enableTwoFADialog.afterClosed().subscribe((action) => { - if (action !== 'enable') { - this.is2FAEnabledToggle = false; - } - }); - - enableTwoFADialog.componentInstance.confirm2FAerror.subscribe((result: boolean) => { - if (result) { - this.is2FAEnabledToggle = false; - } - }); - }); - } else if (event.checked && this.currentUser.is_2fa_enabled) { - this.is2FAswitchingOffSettingsShown = false; - } else { - this.is2FAswitchingOffSettingsShown = true; - } - } - - switchOff2FA() { - this._userService.switchOff2FA(this.authCode).subscribe((res) => { - if (res.disabled) { - this.is2FAswitchingOffSettingsShown = false; - this.authCode = ''; - this.angulartics2.eventTrack.next({ - action: 'User settings: 2fa disabled successfully', - }); - posthog.capture('User settings: 2fa disabled successfully'); - } - }); - } -} diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css deleted file mode 100644 index 54d243efb..000000000 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css +++ /dev/null @@ -1,73 +0,0 @@ -.editor-mode-toggle { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.cedar-hint { - margin: 0 0 8px; - font-size: 12px; - color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54)); -} - -.cedar-hint a { - color: inherit; - text-decoration: underline; -} - -.form-parse-warning { - display: flex; - align-items: center; - gap: 16px; - padding: 12px 16px; - border: 1px solid var(--color-warningPalette-500); - margin-bottom: 12px; - border-radius: 4px; - background: var(--warning-background-color); - color: var(--color-warningPalette-500); - font-size: 13px; -} - -.form-parse-warning mat-icon { - flex-shrink: 0; -} - -.dialog-actions-spacer { - flex: 1; -} - -.code-editor-box { - height: 300px; - border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); - border-radius: 4px; - overflow: hidden; -} - -.code-editor-box ngs-code-editor { - display: block; - height: 100%; -} - -.cedar-validation-errors { - display: flex; - align-items: flex-start; - gap: 16px; - padding: 12px 16px; - border: 1px solid var(--mat-sys-error, #b00020); - margin-top: 8px; - border-radius: 4px; - background: color-mix(in srgb, var(--mat-sys-error, #b00020) 8%, transparent); - color: var(--mat-sys-error, #b00020); - font-size: 13px; -} - -.cedar-validation-errors mat-icon { - flex-shrink: 0; -} - -.cedar-validation-errors__messages { - display: flex; - flex-direction: column; - gap: 4px; -} diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html deleted file mode 100644 index 8c033dcb3..000000000 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html +++ /dev/null @@ -1,64 +0,0 @@ -

Policy — {{ data.groupTitle }}

-
- -
- - Form - Code - -
- - @if (formParseError()) { -
- warning - This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. -
- } - - @if (editorMode() === 'form' && !formParseError()) { - - - } - - @if (editorMode() === 'code') { -

Edit policy in Cedar format

-
- - -
- @if (validationErrors().length > 0) { -
- error -
- @for (error of validationErrors(); track error) { - {{ error }} - } -
-
- } - } -
- - - - @if (editorMode() === 'form' && !formParseError() && !loading() && !policyList?.showAddForm() && policyItems().length) { - - } - - -
diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts deleted file mode 100644 index 8a1dc80e9..000000000 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { CodeEditorModule } from '@ngstack/code-editor'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { CedarValidatorService } from 'src/app/services/cedar-validator.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UsersService } from 'src/app/services/users.service'; -import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; -import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component'; - -type CedarPolicyEditorTestable = CedarPolicyEditorDialogComponent & { - // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals - allTables: ReturnType>; - // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals - availableTables: ReturnType>; - loading: ReturnType>; - // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals - policyItems: ReturnType>; - editorMode: ReturnType>; - cedarPolicy: ReturnType>; - validationErrors: ReturnType>; - submitting: ReturnType>; -}; - -describe('CedarPolicyEditorDialogComponent', () => { - let component: CedarPolicyEditorDialogComponent; - let fixture: ComponentFixture; - let tablesService: TablesService; - let dashboardsService: Partial; - - const mockDialogRef = { - close: () => {}, - backdropClick: () => of(), - keydownEvents: () => of(), - disableClose: false, - }; - - const fakeTables = [ - { - table: 'customers', - display_name: 'Customers', - permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, - }, - { - table: 'orders', - display_name: 'Orders', - permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, - }, - ]; - - const cedarPolicyWithConnection = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:read",', - ' resource == RocketAdmin::Connection::"conn-123"', - ');', - ].join('\n'); - - const mockUsersService: Partial = { - saveCedarPolicy: vi.fn().mockResolvedValue(undefined), - }; - - let mockCedarValidator: Partial; - - beforeEach(() => { - mockCedarValidator = { - validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), - }; - dashboardsService = { - dashboards: signal([ - { id: 'dash-1', name: 'Sales', description: null, connection_id: 'conn-123', created_at: '', updated_at: '' }, - ]).asReadonly(), - setActiveConnection: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [ - MatDialogModule, - MatSnackBarModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - CedarPolicyEditorDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: MAT_DIALOG_DATA, - useValue: { groupId: 'group-123', groupTitle: 'Test Group', cedarPolicy: cedarPolicyWithConnection }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: DashboardsService, useValue: dashboardsService }, - { provide: UsersService, useValue: mockUsersService }, - { provide: CedarValidatorService, useValue: mockCedarValidator }, - ], - }) - .overrideComponent(CedarPolicyEditorDialogComponent, { - remove: { imports: [CodeEditorModule] }, - add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - - tablesService = TestBed.inject(TablesService); - vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); - - fixture = TestBed.createComponent(CedarPolicyEditorDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load tables on init', () => { - const testable = component as unknown as CedarPolicyEditorTestable; - expect(tablesService.fetchTables).toHaveBeenCalled(); - expect(testable.allTables().length).toBe(2); - expect(testable.availableTables().length).toBe(2); - expect(testable.loading()).toBe(false); - }); - - it('should pre-populate policy items from existing cedar policy', () => { - const testable = component as unknown as CedarPolicyEditorTestable; - expect(testable.policyItems().length).toBeGreaterThan(0); - expect(testable.policyItems().some((item) => item.action === 'connection:read')).toBe(true); - }); - - it('should start in form mode', () => { - const testable = component as unknown as CedarPolicyEditorTestable; - expect(testable.editorMode()).toBe('form'); - }); - - it('should switch to code mode', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - await component.onEditorModeChange('code'); - expect(testable.editorMode()).toBe('code'); - expect(testable.cedarPolicy()).toBeTruthy(); - }); - - it('should block save when cedar-wasm reports invalid policy', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - await component.onEditorModeChange('code'); - testable.cedarPolicy.set('invalid policy text {{{'); - (mockCedarValidator.validate as ReturnType).mockResolvedValueOnce({ - valid: false, - errors: ['unexpected token'], - }); - - await component.savePolicy(); - - expect(testable.validationErrors()).toEqual(['unexpected token']); - expect(mockUsersService.saveCedarPolicy).not.toHaveBeenCalled(); - expect(testable.submitting()).toBe(false); - }); - - it('should allow save when cedar-wasm reports valid policy', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - await component.onEditorModeChange('code'); - - await component.savePolicy(); - - expect(testable.validationErrors()).toEqual([]); - expect(mockUsersService.saveCedarPolicy).toHaveBeenCalled(); - }); - - it('should block switching to form mode when policy is invalid', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - await component.onEditorModeChange('code'); - testable.cedarPolicy.set('broken ;;;'); - (mockCedarValidator.validate as ReturnType).mockResolvedValueOnce({ - valid: false, - errors: ['parse error at line 1'], - }); - - await component.onEditorModeChange('form'); - - expect(testable.editorMode()).toBe('code'); - expect(testable.validationErrors()).toEqual(['parse error at line 1']); - }); - - it('should clear validation errors when user edits policy text', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - testable.validationErrors.set(['some old error']); - - component.onCedarPolicyChange('permit(principal, action, resource);'); - - expect(testable.validationErrors()).toEqual([]); - }); - - it('should clear validation errors when switching to code mode', async () => { - const testable = component as unknown as CedarPolicyEditorTestable; - testable.validationErrors.set(['stale error']); - - await component.onEditorModeChange('code'); - - expect(testable.validationErrors()).toEqual([]); - }); -}); diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts deleted file mode 100644 index a750d2656..000000000 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { Component, DestroyRef, ElementRef, Inject, inject, OnInit, signal, ViewChild } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatIconModule } from '@angular/material/icon'; -import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; -import { take } from 'rxjs'; -import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; -import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; -import { canRepresentAsForm, parseCedarDashboardItems, parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { TablePermission } from 'src/app/models/user'; -import { CedarValidatorService } from 'src/app/services/cedar-validator.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { DashboardsService } from 'src/app/services/dashboards.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UiSettingsService } from 'src/app/services/ui-settings.service'; -import { UsersService } from 'src/app/services/users.service'; -import { - AvailableDashboard, - AvailableTable, - CedarPolicyListComponent, -} from '../cedar-policy-list/cedar-policy-list.component'; -import { CedarPolicyEditorDialogComponent as Self } from './cedar-policy-editor-dialog.component'; - -export interface CedarPolicyEditorDialogData { - groupId: string; - groupTitle: string; - cedarPolicy?: string | null; -} - -@Component({ - selector: 'app-cedar-policy-editor-dialog', - templateUrl: './cedar-policy-editor-dialog.component.html', - styleUrls: ['./cedar-policy-editor-dialog.component.css'], - imports: [ - FormsModule, - MatDialogModule, - MatButtonModule, - MatButtonToggleModule, - MatIconModule, - CodeEditorModule, - CedarPolicyListComponent, - ], -}) -export class CedarPolicyEditorDialogComponent implements OnInit { - private _connections = inject(ConnectionsService); - private _usersService = inject(UsersService); - private _uiSettings = inject(UiSettingsService); - private _tablesService = inject(TablesService); - private _dashboardsService = inject(DashboardsService); - private _editorService = inject(CodeEditorService); - private _cedarValidator = inject(CedarValidatorService); - private _destroyRef = inject(DestroyRef); - - protected connectionID: string; - protected cedarPolicy = signal(''); - protected submitting = signal(false); - - protected editorMode = signal<'form' | 'code'>('form'); - protected policyItems = signal([]); - protected availableTables = signal([]); - protected availableDashboards = signal([]); - protected allTables = signal([]); - protected loading = signal(true); - protected formParseError = signal(false); - protected validationErrors = signal([]); - protected validating = signal(false); - - @ViewChild(CedarPolicyListComponent) policyList?: CedarPolicyListComponent; - @ViewChild('dialogContent', { read: ElementRef }) dialogContent?: ElementRef; - - public cedarPolicyModel: object; - public codeEditorOptions = { - minimap: { enabled: false }, - automaticLayout: true, - scrollBeyondLastLine: false, - wordWrap: 'on', - }; - public codeEditorTheme: string; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: CedarPolicyEditorDialogData, - public dialogRef: MatDialogRef, - ) { - this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; - this._editorService.loaded.pipe(take(1)).subscribe(({ monaco }) => registerCedarLanguage(monaco)); - - this.dialogRef.disableClose = true; - this.dialogRef - .backdropClick() - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe(() => { - this.confirmClose(); - }); - this.dialogRef - .keydownEvents() - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe((event) => { - if (event.key === 'Escape') { - this.confirmClose(); - } - }); - } - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this.cedarPolicy.set(this.data.cedarPolicy || ''); - this.cedarPolicyModel = { - language: 'cedar', - uri: `cedar-policy-${this.data.groupId}.cedar`, - value: this.cedarPolicy(), - }; - - this._dashboardsService.setActiveConnection(this.connectionID); - - this._tablesService - .fetchTables(this.connectionID) - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe((tables) => { - const newAllTables: TablePermission[] = []; - const newAvailableTables: AvailableTable[] = []; - for (const t of tables) { - const displayName = t.display_name || normalizeTableName(t.table); - newAllTables.push({ - tableName: t.table, - display_name: displayName, - accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, - }); - newAvailableTables.push({ tableName: t.table, displayName }); - } - - this.allTables.set(newAllTables); - this.availableTables.set(newAvailableTables); - - this.availableDashboards.set( - this._dashboardsService.dashboards().map((d) => ({ - id: d.id, - name: d.name, - })), - ); - - this.loading.set(false); - - const policy = this.cedarPolicy(); - if (policy) { - this.formParseError.set(!canRepresentAsForm(policy)); - if (this.formParseError()) { - this.editorMode.set('code'); - } else { - this.policyItems.set(this._parseCedarToPolicyItems()); - } - } - }); - } - - onCedarPolicyChange(value: string) { - this.cedarPolicy.set(value); - this.validationErrors.set([]); - } - - onPolicyItemsChange(items: CedarPolicyItem[]) { - this.policyItems.set(items); - } - - async onEditorModeChange(mode: 'form' | 'code') { - if (mode === this.editorMode()) return; - - if (mode === 'code') { - this.cedarPolicy.set(policyItemsToCedarPolicy(this.policyItems(), this.connectionID, this.data.groupId)); - this.cedarPolicyModel = { - language: 'cedar', - uri: `cedar-policy-${this.data.groupId}-${Date.now()}.cedar`, - value: this.cedarPolicy(), - }; - this.formParseError.set(false); - this.validationErrors.set([]); - } else { - const policy = this.cedarPolicy(); - const validation = await this._cedarValidator.validate(policy); - if (!validation.valid) { - this.validationErrors.set(validation.errors); - return; - } - this.validationErrors.set([]); - this.formParseError.set(!canRepresentAsForm(policy)); - if (this.formParseError()) return; - this.policyItems.set(this._parseCedarToPolicyItems()); - } - - this.editorMode.set(mode); - } - - confirmClose() { - if (this.editorMode() === 'form' && this.policyList?.hasPendingChanges()) { - const discard = confirm('You have an unsaved policy in the form. Discard it and close?'); - if (!discard) return; - this.policyList.discardPending(); - } - this.dialogRef.close(); - } - - async savePolicy() { - if (this.editorMode() === 'form' && this.policyList?.hasPendingChanges()) { - const discard = confirm('You have an unsaved policy in the form. Discard it and save?'); - if (!discard) return; - this.policyList.discardPending(); - } - - this.submitting.set(true); - - if (this.editorMode() === 'form') { - this.cedarPolicy.set(policyItemsToCedarPolicy(this.policyItems(), this.connectionID, this.data.groupId)); - } - - const policy = this.cedarPolicy(); - if (!policy) { - this.submitting.set(false); - this.dialogRef.close(); - return; - } - - const validation = await this._cedarValidator.validate(policy); - if (!validation.valid) { - this.validationErrors.set(validation.errors); - this.submitting.set(false); - return; - } - this.validationErrors.set([]); - - try { - await this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, policy); - this.dialogRef.close(); - } finally { - this.submitting.set(false); - } - } - - onAddPolicyClick() { - if (!this.policyList) return; - this.policyList.showAddForm.set(true); - setTimeout(() => { - const el = this.dialogContent?.nativeElement; - if (el) { - el.scrollTop = el.scrollHeight; - } - }); - } - - private _parseCedarToPolicyItems(): CedarPolicyItem[] { - const policy = this.cedarPolicy(); - const parsed = parseCedarPolicy(policy, this.connectionID, this.data.groupId, this.allTables()); - const dashboardItems = parseCedarDashboardItems(policy, this.connectionID); - return [...permissionsToPolicyItems(parsed), ...dashboardItems]; - } -} diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css deleted file mode 100644 index 7f9500d4a..000000000 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css +++ /dev/null @@ -1,269 +0,0 @@ -.policy-list { - display: flex; - flex-direction: column; - gap: 16px; -} - -.empty-state { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 12px; - font-size: 14px; - padding: 12px 0; -} - -.empty-state p { - margin: 0; - opacity: 0.7; -} - -/* ── Group ── */ - -.policy-group { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 12px; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - .policy-group { - border-color: rgba(255, 255, 255, 0.12); - } -} - -.policy-group__header { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - background: rgba(0, 0, 0, 0.03); - cursor: pointer; - user-select: none; -} - -.policy-group__header:hover { - background: rgba(0, 0, 0, 0.06); -} - -@media (prefers-color-scheme: dark) { - .policy-group__header { - background: rgba(0, 0, 0, 0.2); - } - .policy-group__header:hover { - background: rgba(255, 255, 255, 0.07); - } -} - -.policy-group__icon-box { - width: 28px; - height: 28px; - border-radius: 7px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.policy-group__icon-box mat-icon { - font-size: 18px; - width: 18px; - height: 18px; -} - -.policy-group__icon-box--general { - background: var(--color-warningPalette-100); - color: var(--color-warningPalette-500); -} - -.policy-group__icon-box--connection { - background: var(--color-alternativePalette-100); - color: var(--color-alternativePalette-500); -} - -.policy-group__icon-box--group { - background: color-mix(in srgb, #06b6d4, transparent 85%); - color: #0891b2; -} - -.policy-group__icon-box--table { - background: var(--color-successPalette-100); - color: var(--color-successPalette-500); -} - -.policy-group__icon-box--dashboard { - background: var(--color-infoPalette-100); - color: var(--color-infoPalette-500); -} - -@media (prefers-color-scheme: dark) { - .policy-group__icon-box--general { - background: var(--color-warningPalette-900); - color: var(--color-warningPalette-500); - } - .policy-group__icon-box--connection { - background: var(--color-alternativeDarkPalette-900); - color: var(--color-alternativeDarkPalette-500); - } - .policy-group__icon-box--group { - background: rgba(6, 182, 212, 0.15); - color: #22d3ee; - } - .policy-group__icon-box--table { - background: var(--color-successDarkPalette-900); - color: var(--color-successDarkPalette-500); - } - .policy-group__icon-box--dashboard { - background: var(--color-infoPalette-900); - color: var(--color-infoPalette-500); - } -} - -.policy-group__label { - font-size: 13px; - font-weight: 600; -} - -.policy-group__count { - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 600; - padding: 2px 7px; - border-radius: 10px; - background: rgba(0, 0, 0, 0.08); - color: rgba(0, 0, 0, 0.64); - margin-left: auto; - height: 20px; - width: 20px; -} - -.policy-group__chevron { - font-size: 20px; - width: 20px; - height: 20px; - opacity: 0.4; - transform: rotate(-90deg); - transition: transform 0.2s ease; -} - -.policy-group__chevron--collapsed { - transform: rotate(0deg); -} - -@media (prefers-color-scheme: dark) { - .policy-group__count { - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.64); - } -} - -.policy-group__items { - display: flex; - flex-direction: column; -} - -/* ── Policy item ── */ - -.policy-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 16px; - border-top: 1px solid rgba(0, 0, 0, 0.06); -} - -@media (prefers-color-scheme: dark) { - .policy-item { - border-top-color: rgba(255, 255, 255, 0.06); - } -} - -.policy-item--add { - border: 1px dashed rgba(0, 0, 0, 0.15); - border-radius: 12px; - padding: 10px 16px; -} - -@media (prefers-color-scheme: dark) { - .policy-item--add { - border-color: rgba(255, 255, 255, 0.15); - } -} - -.policy-item__content { - display: flex; - align-items: center; - gap: 6px; - flex: 1; - min-width: 0; -} - -.policy-item__action-icon { - font-size: 16px; - width: 16px; - height: 16px; - opacity: 0.5; -} - -.policy-item__label { - font-size: 13px; - font-weight: 500; -} - -.policy-item__table { - font-size: 13px; - opacity: 0.55; - font-style: italic; -} - -.policy-item__actions { - display: flex; - align-items: center; - flex-shrink: 0; -} - -.policy-item__edit-form { - display: flex; - align-items: flex-start; - gap: 8px; - flex: 1; - flex-wrap: wrap; -} - -.policy-field { - flex: 1; - min-width: 180px; -} - -.policy-item__edit-actions { - display: flex; - align-items: center; - gap: 4px; - padding-top: 8px; -} - -/* ── Option hints ── */ - -/* .policy-option--used { - background: color-mix(in srgb, #3b82f6, transparent 92%); -} */ - -.policy-option--used[data-hint]::after { - content: attr(data-hint); - font-size: 11px; - opacity: 0.5; - margin-left: 8px; - font-style: italic; -} - -/* @media (prefers-color-scheme: dark) { - .policy-option--used { - background: rgba(59, 130, 246, 0.1); - } -} */ - -.add-policy-button { - align-self: flex-start; -} diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html deleted file mode 100644 index b2e91b37d..000000000 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html +++ /dev/null @@ -1,184 +0,0 @@ -
- @if (loading()) { - - } - - @if (!loading() && policies().length === 0 && !showAddForm()) { -
-

No policies defined. Add a policy to grant permissions.

- -
- } - - @for (group of groupedPolicies(); track group.label) { -
-
-
- {{ group.icon }} -
- {{ group.label }} - {{ group.policies.length }} - expand_more -
- - @if (!isCollapsed(group.label)) { -
- @for (entry of group.policies; track entry.originalIndex) { -
- @if (editingIndex !== entry.originalIndex) { - -
- {{ getActionIcon(entry.item.action) }} - {{ getShortActionLabel(entry.item.action) }} - @if (entry.item.tableName) { - - {{ getTableDisplayName(entry.item.tableName) }} - - } - @if (entry.item.dashboardId) { - - {{ getDashboardDisplayName(entry.item.dashboardId) }} - - } -
-
- - -
- } @else { - -
- - Action - - @for (actionGroup of editActionGroups; track actionGroup.group) { - - @for (action of actionGroup.actions; track action.value) { - - {{ getActionLabel(action.value) }} - - } - - } - - - - @if (editNeedsTable) { - - Table - - All tables - @for (table of availableTables(); track table.tableName) { - - {{ table.displayName }} - - } - - - } - - @if (editNeedsDashboard) { - - Dashboard - - All dashboards - @for (dashboard of availableDashboards(); track dashboard.id) { - - {{ dashboard.name }} - - } - - - } - -
- - -
-
- } -
- } -
- } -
- } - - - @if (showAddForm()) { -
-
- - Action - - @for (group of addActionGroups(); track group.group) { - - @for (action of group.actions; track action.value) { - - {{ getActionLabel(action.value) }} - - } - - } - - - - @if (needsTable) { - - Table - - All tables - @for (table of availableTables(); track table.tableName) { - - {{ table.displayName }} - - } - - - } - - @if (needsDashboard) { - - Dashboard - - All dashboards - @for (dashboard of availableDashboards(); track dashboard.id) { - - {{ dashboard.name }} - - } - - - } - -
- - -
-
-
- } - -
diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts deleted file mode 100644 index 9e04bdf87..000000000 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items'; -import { UsersService } from 'src/app/services/users.service'; -import { CedarPolicyListComponent } from './cedar-policy-list.component'; - -const fixtureGroups: PolicyActionGroup[] = [ - { - group: 'Connection', - actions: [ - { value: 'connection:read', resource: 'connection' }, - { value: 'connection:edit', resource: 'connection' }, - ], - }, - { - group: 'Group', - actions: [ - { value: 'group:read', resource: 'group' }, - { value: 'group:edit', resource: 'group' }, - ], - }, - { - group: 'Table', - actions: [ - { value: 'table:read', resource: 'table' }, - { value: 'table:edit', resource: 'table' }, - ], - }, - { - group: 'Dashboard', - actions: [ - { value: 'dashboard:read', resource: 'dashboard' }, - { value: 'dashboard:create', resource: 'dashboard' }, - { value: 'dashboard:edit', resource: 'dashboard' }, - ], - }, -]; - -const flatActions: PolicyAction[] = fixtureGroups.flatMap((g) => g.actions); - -describe('CedarPolicyListComponent', () => { - let component: CedarPolicyListComponent; - let fixture: ComponentFixture; - - const fakeTables = [ - { tableName: 'customers', displayName: 'Customers' }, - { tableName: 'orders', displayName: 'Orders' }, - ]; - - const fakeDashboards = [ - { id: 'dash-1', name: 'Sales Dashboard' }, - { id: 'dash-2', name: 'Analytics' }, - ]; - - beforeEach(async () => { - const groupsSignal = signal(fixtureGroups); - const actionsSignal = signal(flatActions); - const mockUsersService: Partial = { - availablePermissionGroups: groupsSignal.asReadonly() as UsersService['availablePermissionGroups'], - availablePermissions: actionsSignal.asReadonly() as UsersService['availablePermissions'], - }; - - await TestBed.configureTestingModule({ - imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule], - providers: [{ provide: UsersService, useValue: mockUsersService }], - }).compileComponents(); - - fixture = TestBed.createComponent(CedarPolicyListComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('availableTables', fakeTables); - fixture.componentRef.setInput('availableDashboards', fakeDashboards); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should add a policy', () => { - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.showAddForm.set(true); - component.newAction = 'connection:read'; - component.addPolicy(); - - expect(emitted).not.toBeNull(); - expect(emitted!.length).toBe(1); - expect(emitted![0].action).toBe('connection:read'); - expect(component.showAddForm()).toBe(false); - }); - - it('should add a table policy with tableName', () => { - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.showAddForm.set(true); - component.newAction = 'table:read'; - component.newTableName = 'customers'; - component.addPolicy(); - - expect(emitted!.length).toBe(1); - expect(emitted![0].action).toBe('table:read'); - expect(emitted![0].tableName).toBe('customers'); - }); - - it('should add a table policy with wildcard tableName', () => { - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.showAddForm.set(true); - component.newAction = 'table:edit'; - component.newTableName = '*'; - component.addPolicy(); - - expect(emitted!.length).toBe(1); - expect(emitted![0].action).toBe('table:edit'); - expect(emitted![0].tableName).toBe('*'); - }); - - it('should not add policy without action', () => { - let emitted = false; - component.policiesChange.subscribe(() => (emitted = true)); - - component.showAddForm.set(true); - component.newAction = ''; - component.addPolicy(); - - expect(emitted).toBe(false); - }); - - it('should not add table policy without table name', () => { - let emitted = false; - component.policiesChange.subscribe(() => (emitted = true)); - - component.showAddForm.set(true); - component.newAction = 'table:read'; - component.newTableName = ''; - component.addPolicy(); - - expect(emitted).toBe(false); - }); - - it('should remove a policy', () => { - fixture.componentRef.setInput('policies', [{ action: 'connection:read' }, { action: 'group:read' }]); - fixture.detectChanges(); - - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.removePolicy(0); - - expect(emitted!.length).toBe(1); - expect(emitted![0].action).toBe('group:read'); - }); - - it('should start and save edit', () => { - fixture.componentRef.setInput('policies', [{ action: 'connection:read' }]); - fixture.detectChanges(); - - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.startEdit(0); - expect(component.editingIndex).toBe(0); - expect(component.editAction).toBe('connection:read'); - - component.editAction = 'connection:edit'; - component.saveEdit(0); - - expect(emitted![0].action).toBe('connection:edit'); - expect(component.editingIndex).toBeNull(); - }); - - it('should cancel edit', () => { - fixture.componentRef.setInput('policies', [{ action: 'connection:read' }]); - fixture.detectChanges(); - - component.startEdit(0); - component.editAction = 'connection:edit'; - component.cancelEdit(); - - expect(component.editingIndex).toBeNull(); - expect(component.policies()[0].action).toBe('connection:read'); - }); - - it('should return correct action labels', () => { - expect(component.getActionLabel('*')).toBe('Full access (all permissions)'); - expect(component.getActionLabel('connection:read')).toBe('Connection read'); - expect(component.getActionLabel('table:edit')).toBe('Table edit'); - expect(component.getActionLabel('table:*')).toBe('Full table access'); - }); - - it('should return correct table display names', () => { - expect(component.getTableDisplayName('customers')).toBe('Customers'); - expect(component.getTableDisplayName('unknown')).toBe('unknown'); - expect(component.getTableDisplayName('*')).toBe('All tables'); - }); - - it('should detect needsTable correctly', () => { - component.newAction = 'connection:read'; - expect(component.needsTable).toBe(false); - - component.newAction = 'table:read'; - expect(component.needsTable).toBe(true); - - component.newAction = 'table:*'; - expect(component.needsTable).toBe(true); - }); - - it('should reset add form', () => { - component.showAddForm.set(true); - component.newAction = 'connection:read'; - component.newTableName = 'test'; - component.resetAddForm(); - - expect(component.showAddForm()).toBe(false); - expect(component.newAction).toBe(''); - expect(component.newTableName).toBe(''); - }); - - it('should add a dashboard policy with dashboardId', () => { - let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; - component.policiesChange.subscribe((v) => (emitted = v)); - - component.showAddForm.set(true); - component.newAction = 'dashboard:read'; - component.newDashboardId = 'dash-1'; - component.addPolicy(); - - expect(emitted!.length).toBe(1); - expect(emitted![0].action).toBe('dashboard:read'); - expect(emitted![0].dashboardId).toBe('dash-1'); - }); - - it('should not add dashboard policy without dashboard id', () => { - let emitted = false; - component.policiesChange.subscribe(() => (emitted = true)); - - component.showAddForm.set(true); - component.newAction = 'dashboard:edit'; - component.newDashboardId = ''; - component.addPolicy(); - - expect(emitted).toBe(false); - }); - - it('should detect needsDashboard correctly', () => { - component.newAction = 'connection:read'; - expect(component.needsDashboard).toBe(false); - - component.newAction = 'dashboard:read'; - expect(component.needsDashboard).toBe(true); - }); - - it('should treat dashboard:create as scopeless', () => { - component.newAction = 'dashboard:create'; - expect(component.needsDashboard).toBe(false); - }); - - it('should return correct dashboard display names', () => { - expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard'); - expect(component.getDashboardDisplayName('unknown')).toBe('unknown'); - expect(component.getDashboardDisplayName('*')).toBe('All dashboards'); - }); - - it('should synthesize General and prefix wildcards in addActionGroups', () => { - const testable = component as CedarPolicyListComponent & { - addActionGroups: () => PolicyActionGroup[]; - }; - const groups = testable.addActionGroups(); - const general = groups.find((g) => g.group === 'General'); - expect(general).toBeTruthy(); - expect(general!.actions[0].value).toBe('*'); - - const table = groups.find((g) => g.group === 'Table'); - expect(table).toBeTruthy(); - expect(table!.actions[0].value).toBe('table:*'); - expect(table!.actions[0].resource).toBe('table'); - - const dashboard = groups.find((g) => g.group === 'Dashboard'); - expect(dashboard).toBeTruthy(); - expect(dashboard!.actions[0].value).toBe('dashboard:*'); - - const connection = groups.find((g) => g.group === 'Connection'); - expect(connection!.actions[0].value).toBe('connection:read'); - }); -}); diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts deleted file mode 100644 index 5d416cd5d..000000000 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, input, output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { CedarPolicyItem, PolicyAction, PolicyActionGroup, PolicyActionResource } from 'src/app/lib/cedar-policy-items'; -import { actionIcon, actionLabel, actionShortLabel } from 'src/app/lib/permission-display'; -import { UsersService } from 'src/app/services/users.service'; -import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; - -export interface AvailableTable { - tableName: string; - displayName: string; -} - -export interface AvailableDashboard { - id: string; - name: string; -} - -export interface PolicyGroup { - label: string; - description: string; - icon: string; - colorClass: string; - policies: { item: CedarPolicyItem; originalIndex: number }[]; -} - -const WILDCARD_PREFIXES: PolicyActionResource[] = ['table', 'dashboard', 'panel']; - -@Component({ - selector: 'app-cedar-policy-list', - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatFormFieldModule, - MatIconModule, - MatSelectModule, - MatTooltipModule, - ContentLoaderComponent, - ], - templateUrl: './cedar-policy-list.component.html', - styleUrls: ['./cedar-policy-list.component.css'], -}) -export class CedarPolicyListComponent { - readonly policies = input([]); - readonly availableTables = input([]); - readonly availableDashboards = input([]); - readonly loading = input(false); - readonly policiesChange = output(); - - readonly showAddForm = signal(false); - newAction = ''; - newTableName = ''; - newDashboardId = ''; - - editingIndex: number | null = null; - editAction = ''; - editTableName = ''; - editDashboardId = ''; - - collapsedGroups = new Set(); - - private _users = inject(UsersService); - - private displayGroups = computed(() => this._buildDisplayGroups()); - private displayActions = computed(() => this.displayGroups().flatMap((g) => g.actions)); - - protected groupedPolicies = computed(() => this._computeGroupedPolicies()); - protected addActionGroups = computed(() => this._buildFilteredGroups(-1)); - - get needsTable(): boolean { - return this._needsTable(this.newAction); - } - - get needsDashboard(): boolean { - return this._needsDashboard(this.newAction); - } - - get editNeedsTable(): boolean { - return this._needsTable(this.editAction); - } - - get editNeedsDashboard(): boolean { - return this._needsDashboard(this.editAction); - } - - protected usedTables = computed(() => { - const map = new Map(); - for (const p of this.policies()) { - if (p.tableName) { - const labels = map.get(p.tableName) || []; - labels.push(this.getShortActionLabel(p.action)); - map.set(p.tableName, labels); - } - } - return map; - }); - - protected usedDashboards = computed(() => { - const map = new Map(); - for (const p of this.policies()) { - if (p.dashboardId) { - const labels = map.get(p.dashboardId) || []; - labels.push(this.getShortActionLabel(p.action)); - map.set(p.dashboardId, labels); - } - } - return map; - }); - - toggleGroup(label: string) { - if (this.collapsedGroups.has(label)) { - this.collapsedGroups.delete(label); - } else { - this.collapsedGroups.add(label); - } - } - - isCollapsed(label: string): boolean { - return this.collapsedGroups.has(label); - } - - getTableUsedHint(tableName: string): string { - return this.usedTables().get(tableName)?.join(', ') || ''; - } - - getDashboardUsedHint(dashboardId: string): string { - return this.usedDashboards().get(dashboardId)?.join(', ') || ''; - } - - getActionIcon(action: string): string { - return actionIcon(action); - } - - getShortActionLabel(action: string): string { - return actionShortLabel(action); - } - - getActionLabel(action: string): string { - return actionLabel(action); - } - - getTableDisplayName(tableName: string): string { - if (tableName === '*') return 'All tables'; - return this.availableTables().find((t) => t.tableName === tableName)?.displayName || tableName; - } - - getDashboardDisplayName(dashboardId: string): string { - if (dashboardId === '*') return 'All dashboards'; - return this.availableDashboards().find((d) => d.id === dashboardId)?.name || dashboardId; - } - - hasPendingChanges(): boolean { - return (this.showAddForm() && !!this.newAction) || this.editingIndex !== null; - } - - discardPending() { - if (this.showAddForm()) this.resetAddForm(); - if (this.editingIndex !== null) this.cancelEdit(); - } - - addPolicy() { - if (!this.newAction) return; - if (this.needsTable && !this.newTableName) return; - if (this.needsDashboard && !this.newDashboardId) return; - - const currentPolicies = this.policies(); - const duplicate = currentPolicies.some((p) => { - if (p.action !== this.newAction) return false; - if (this.needsTable) return p.tableName === this.newTableName; - if (this.needsDashboard) return p.dashboardId === this.newDashboardId; - return true; - }); - if (duplicate) return; - - const item: CedarPolicyItem = { action: this.newAction }; - if (this.needsTable) { - item.tableName = this.newTableName; - } - if (this.needsDashboard) { - item.dashboardId = this.newDashboardId; - } - this.policiesChange.emit([...currentPolicies, item]); - this.resetAddForm(); - } - - removePolicy(index: number) { - this.policiesChange.emit(this.policies().filter((_, i) => i !== index)); - } - - startEdit(index: number) { - const p = this.policies()[index]; - this.editingIndex = index; - this.editAction = p.action; - this.editTableName = p.tableName || ''; - this.editDashboardId = p.dashboardId || ''; - } - - get editActionGroups(): PolicyActionGroup[] { - return this._buildFilteredGroups(this.editingIndex ?? -1); - } - - saveEdit(index: number) { - if (!this.editAction) return; - if (this.editNeedsTable && !this.editTableName) return; - if (this.editNeedsDashboard && !this.editDashboardId) return; - - const updated = [...this.policies()]; - updated[index] = { - action: this.editAction, - tableName: this.editNeedsTable ? this.editTableName : undefined, - dashboardId: this.editNeedsDashboard ? this.editDashboardId : undefined, - }; - this.policiesChange.emit(updated); - this.editingIndex = null; - } - - cancelEdit() { - this.editingIndex = null; - } - - resetAddForm() { - this.showAddForm.set(false); - this.newAction = ''; - this.newTableName = ''; - this.newDashboardId = ''; - } - - private _groupConfig = [ - { - prefix: '*', - label: 'General', - description: 'Full access to everything', - icon: 'admin_panel_settings', - colorClass: 'general', - }, - { - prefix: 'connection:', - label: 'Connection', - description: 'Connection settings access', - icon: 'cable', - colorClass: 'connection', - }, - { prefix: 'group:', label: 'Group', description: 'User group management', icon: 'group', colorClass: 'group' }, - { - prefix: 'table:', - label: 'Table', - description: 'Table data operations', - icon: 'table_chart', - colorClass: 'table', - }, - { - prefix: 'actionEvent:', - label: 'ActionEvent', - description: 'Custom action event triggers', - icon: 'play_arrow', - colorClass: 'action-event', - }, - { - prefix: 'dashboard:', - label: 'Dashboard', - description: 'Dashboard access', - icon: 'dashboard', - colorClass: 'dashboard', - }, - { - prefix: 'panel:', - label: 'Panel', - description: 'Panel access', - icon: 'view_quilt', - colorClass: 'panel', - }, - ]; - - private _needsTable(value: string): boolean { - return this._scopeResource(value) === 'table'; - } - - private _needsDashboard(value: string): boolean { - return this._scopeResource(value) === 'dashboard'; - } - - private _scopeResource(value: string): PolicyActionResource | undefined { - if (value === 'dashboard:create') return undefined; - return this._findAction(value)?.resource; - } - - private _findAction(value: string): PolicyAction | undefined { - return this.displayActions().find((a) => a.value === value); - } - - private _isSimpleAction(action: PolicyAction): boolean { - if (action.value === 'dashboard:create') return true; - return action.resource !== 'table' && action.resource !== 'dashboard'; - } - - private _buildDisplayGroups(): PolicyActionGroup[] { - const general: PolicyActionGroup = { group: 'General', actions: [{ value: '*' }] }; - const groups = this._users.availablePermissionGroups().map((g) => this._withWildcardEntry(g)); - return [general, ...groups]; - } - - private _withWildcardEntry(group: PolicyActionGroup): PolicyActionGroup { - if (group.actions.length === 0) return group; - const prefix = group.actions[0].value.split(':')[0] as PolicyActionResource; - if (!WILDCARD_PREFIXES.includes(prefix)) return group; - const wildcard: PolicyAction = { value: `${prefix}:*`, resource: prefix }; - return { ...group, actions: [wildcard, ...group.actions] }; - } - - private _computeGroupedPolicies(): PolicyGroup[] { - const policies = this.policies(); - return this._groupConfig - .map((cfg) => ({ - label: cfg.label, - description: cfg.description, - icon: cfg.icon, - colorClass: cfg.colorClass, - policies: policies - .map((item, i) => ({ item, originalIndex: i })) - .filter(({ item }) => (cfg.prefix === '*' ? item.action === '*' : item.action.startsWith(cfg.prefix))), - })) - .filter((g) => g.policies.length > 0); - } - - private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { - const policies = this.policies(); - const actions = this.displayActions(); - const existingSimple = new Set( - policies - .filter((p, i) => { - if (i === excludeIndex) return false; - const def = actions.find((a) => a.value === p.action); - return def != null && this._isSimpleAction(def); - }) - .map((p) => p.action), - ); - - return this.displayGroups() - .map((group) => ({ - ...group, - actions: group.actions.filter((action) => { - if (this._isSimpleAction(action)) { - return !existingSimple.has(action.value); - } - return true; - }), - })) - .filter((group) => group.actions.length > 0); - } -} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css deleted file mode 100644 index fcaa8cf92..000000000 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.mat-mdc-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html deleted file mode 100644 index 3cb5c4d36..000000000 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html +++ /dev/null @@ -1,19 +0,0 @@ -

Create group of users

-
- - - Enter group title - - @if (title.errors?.required && (title.invalid && title.touched)) { - Title should not be empty. - } - - - - - - -
diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts deleted file mode 100644 index dc315ec80..000000000 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupAddDialogComponent } from './group-add-dialog.component'; - -type GroupAddTestable = GroupAddDialogComponent & { - submitting: ReturnType>; - connectionID: string; - groupTitle: string; - addGroup: () => Promise; -}; - -describe('GroupAddDialogComponent', () => { - let component: GroupAddDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: vi.fn(), - }; - - const mockUsersService: Partial = { - createGroup: vi.fn().mockResolvedValue({ id: 'new-group', title: 'Sellers' }), - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatDialogModule, - Angulartics2Module.forRoot({}), - GroupAddDialogComponent, - BrowserAnimationsModule, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: UsersService, useValue: mockUsersService }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(GroupAddDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should call create group service', async () => { - const testable = component as unknown as GroupAddTestable; - testable.connectionID = '12345678'; - testable.groupTitle = 'Sellers'; - - await testable.addGroup(); - - expect(mockUsersService.createGroup).toHaveBeenCalledWith('12345678', 'Sellers'); - expect(testable.submitting()).toBe(false); - }); -}); diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts deleted file mode 100644 index cab8c2f38..000000000 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { Angulartics2 } from 'angulartics2'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupAddDialogComponent } from './group-add-dialog.component'; - -const mockConnectionsService: Partial = { - currentConnectionID: 'conn-1', -}; - -const mockUsersService: Partial = { - createGroup: () => Promise.resolve(null), -}; - -const mockAngulartics: Partial = { - eventTrack: { next: () => {} } as any, -}; - -const meta: Meta = { - title: 'Dialogs/GroupAdd', - component: GroupAddDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - { provide: UsersService, useValue: mockUsersService }, - { provide: Angulartics2, useValue: mockAngulartics }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts deleted file mode 100644 index 1b4d27a25..000000000 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Component, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UsersService } from 'src/app/services/users.service'; - -@Component({ - selector: 'app-group-add-dialog', - imports: [FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], - templateUrl: './group-add-dialog.component.html', - styleUrls: ['./group-add-dialog.component.css'], -}) -export class GroupAddDialogComponent { - private _connections = inject(ConnectionsService); - private _usersService = inject(UsersService); - private _angulartics2 = inject(Angulartics2); - protected dialogRef = inject>(MatDialogRef); - - protected connectionID = this._connections.currentConnectionID; - protected groupTitle = ''; - protected submitting = signal(false); - - async addGroup() { - this.submitting.set(true); - try { - const res = await this._usersService.createGroup(this.connectionID, this.groupTitle); - this.dialogRef.close(res); - this._angulartics2.eventTrack.next({ - action: 'User groups: user groups was created successfully', - }); - posthog.capture('User groups: user groups was created successfully'); - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.css b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html deleted file mode 100644 index 87de184ac..000000000 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html +++ /dev/null @@ -1,14 +0,0 @@ -

Confirm users group delete

- -

You are going to delete {{ data.title }} group.

-
-

Please confirm.

-
- - - - diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts deleted file mode 100644 index 6a0357aaf..000000000 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Angulartics2Module } from 'angulartics2'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupDeleteDialogComponent } from './group-delete-dialog.component'; - -type GroupDeleteTestable = GroupDeleteDialogComponent & { - submitting: ReturnType>; -}; - -describe('GroupDeleteDialogComponent', () => { - let component: GroupDeleteDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: vi.fn(), - }; - - const mockUsersService: Partial = { - deleteGroup: vi.fn().mockResolvedValue(undefined), - }; - - beforeEach(async () => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, Angulartics2Module.forRoot(), GroupDeleteDialogComponent], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: { id: '12345678-123', title: 'Test' } }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: UsersService, useValue: mockUsersService }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(GroupDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should call delete user group service', async () => { - const testable = component as unknown as GroupDeleteTestable; - - await testable.deleteUsersGroup('12345678-123'); - - expect(mockUsersService.deleteGroup).toHaveBeenCalledWith('12345678-123'); - expect(testable.submitting()).toBe(false); - }); -}); diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts deleted file mode 100644 index be54dcb85..000000000 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Component, Inject, inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { UsersService } from 'src/app/services/users.service'; - -@Component({ - selector: 'app-group-delete-dialog', - templateUrl: './group-delete-dialog.component.html', - styleUrls: ['./group-delete-dialog.component.css'], - imports: [MatDialogModule, MatButtonModule], -}) -export class GroupDeleteDialogComponent { - private _usersService = inject(UsersService); - private _angulartics2 = inject(Angulartics2); - protected dialogRef = inject>(MatDialogRef); - - protected submitting = signal(false); - - // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type - constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - - async deleteUsersGroup(id: string) { - this.submitting.set(true); - try { - await this._usersService.deleteGroup(id); - this.dialogRef.close(); - this._angulartics2.eventTrack.next({ - action: 'User groups: user group was deleted successfully', - }); - posthog.capture('User groups: user group was deleted successfully'); - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css deleted file mode 100644 index fcaa8cf92..000000000 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.css +++ /dev/null @@ -1,3 +0,0 @@ -.mat-mdc-form-field { - width: 100%; -} diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html deleted file mode 100644 index 3a25b3f8d..000000000 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html +++ /dev/null @@ -1,19 +0,0 @@ -

Change group name

-
- - - Change group name - - @if (title.errors?.required && (title.invalid && title.touched)) { - Title should not be empty. - } - - - - - - -
diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts deleted file mode 100644 index 57721c04d..000000000 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupNameEditDialogComponent } from './group-name-edit-dialog.component'; - -describe('GroupNameEditDialogComponent', () => { - let component: GroupNameEditDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: vi.fn(), - }; - - const mockUsersService: Partial = { - editGroupName: vi.fn().mockResolvedValue(undefined), - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - MatDialogModule, - MatSnackBarModule, - FormsModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot({}), - GroupNameEditDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { - provide: MAT_DIALOG_DATA, - useValue: { id: 'test-id', title: 'Test Group' }, - }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: UsersService, useValue: mockUsersService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(GroupNameEditDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts deleted file mode 100644 index 307170d33..000000000 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Component, Inject, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.component'; - -@Component({ - selector: 'app-group-name-edit-dialog', - templateUrl: './group-name-edit-dialog.component.html', - styleUrls: ['./group-name-edit-dialog.component.css'], - imports: [MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], -}) -export class GroupNameEditDialogComponent { - private _usersService = inject(UsersService); - protected dialogRef = inject>(MatDialogRef); - - protected groupTitle: string; - protected submitting = signal(false); - - constructor(@Inject(MAT_DIALOG_DATA) public group: { id: string; title: string }) { - this.groupTitle = this.group.title; - } - - async addGroup() { - this.submitting.set(true); - try { - await this._usersService.editGroupName(this.group.id, this.groupTitle); - this.dialogRef.close(); - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css deleted file mode 100644 index 33b763e26..000000000 --- a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css +++ /dev/null @@ -1,101 +0,0 @@ -.public-access-panel { - margin-bottom: 20px; -} - -.mat-expansion-panel-header-title { - align-items: center; - row-gap: 0; - line-height: 1.3; -} - -.public-access-icon { - margin-right: 8px; - flex-shrink: 0; - opacity: 0.7; -} - -/* Mirrors .group-system-badge on the parent permissions page. */ -.public-access-badge { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 2px 6px; - border-radius: 10px; - background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 88%); - color: var(--color-alternativePalette-500); - margin-left: 8px; - white-space: nowrap; -} - -.public-access-badge_on { - background: color-mix(in srgb, var(--color-warningPalette-500), transparent 88%); - color: var(--color-warningPalette-500); -} - -.public-access-warning { - display: flex; - align-items: flex-start; - gap: 16px; - padding: 12px 16px; - border: 1px solid var(--color-warningPalette-500); - border-radius: 4px; - background: var(--warning-background-color); - color: var(--color-warningPalette-500); - font-size: 13px; - margin-bottom: 16px; -} - -.public-access-warning mat-icon { - flex-shrink: 0; -} - -.public-access-tables { - list-style: none; - margin: 0; - padding: 0; -} - -.public-access-table { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - padding: 6px 0; -} - -.public-access-table_selected { - padding-bottom: 12px; -} - -.public-access-columns { - flex: 1 1 16em; - min-width: 12em; -} - -.public-access-columns-hint { - font-size: 12px; - opacity: 0.6; -} - -.public-access-actions { - display: flex; - align-items: center; - margin-top: 16px; -} - -.public-access-actions-spacer { - flex: 1; -} - -@media (width <= 600px) { - .public-access-table { - align-items: flex-start; - flex-direction: column; - gap: 4px; - } - - .public-access-columns { - width: 100%; - } -} diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html deleted file mode 100644 index 061e12258..000000000 --- a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - public - Public access - - {{ statusLabel() }} - - - - - -
- warning - - Anyone on the internet can read the tables listed below without signing in. - Public access is read-only — rows can never be added, edited or deleted. - -
- - @if (tablesLoading() || loadingPermissions()) { - - } @else if (tables().length === 0) { -

No tables in this connection.

- } @else { -
    - @for (table of tables(); track table.tableName) { - @let selected = isSelected(table.tableName); -
  • - - {{ table.displayName }} - - - @if (selected) { - @if (isLoadingColumns(table.tableName)) { - Loading columns… - } @else { - - Readable columns - - @for (column of availableColumns(table.tableName); track column) { - {{ column }} - } - - Leave empty to expose all columns - - } - } -
  • - } -
- -
- @if (canDisable()) { - - } - - -
- } -
-
diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts deleted file mode 100644 index a32795894..000000000 --- a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Signal, signal, WritableSignal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { of, throwError } from 'rxjs'; -import { PublicPermissions } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UsersService } from 'src/app/services/users.service'; -import { PublicAccessPanelComponent } from './public-access-panel.component'; - -type PublicAccessPanelTestable = PublicAccessPanelComponent & { - statusLabel: Signal; - selectedCount: Signal; - canDisable: Signal; - tablesLoading: WritableSignal; - submitting: WritableSignal; - tables: Signal>; -}; - -const CONNECTION_ID = '5a2d4e0c-6d3a-4b0f-9d1a-4a0f0a4c8b11'; - -const fakeTables = [ - { table: 'customers', display_name: 'Customers' }, - { table: 'orders', display_name: '' }, -]; - -const fakeStructure = { - structure: [{ column_name: 'id' }, { column_name: 'name' }, { column_name: 'secret' }], -}; - -describe('PublicAccessPanelComponent', () => { - let component: PublicAccessPanelComponent; - let testable: PublicAccessPanelTestable; - let fixture: ComponentFixture; - let publicPermissions: WritableSignal; - let mockUsersService: Partial; - let mockTablesService: Partial; - - beforeEach(async () => { - publicPermissions = signal({ enabled: false, tables: [] }); - - mockUsersService = { - publicPermissions: publicPermissions.asReadonly(), - publicPermissionsLoading: signal(false).asReadonly(), - loadPublicPermissions: vi.fn(), - savePublicPermissions: vi.fn().mockResolvedValue(undefined), - }; - - mockTablesService = { - fetchTables: vi.fn().mockReturnValue(of(fakeTables)), - fetchTableStructure: vi.fn().mockReturnValue(of(fakeStructure)), - }; - - const mockConnectionsService: Partial = { - get currentConnectionID() { - return CONNECTION_ID; - }, - }; - - await TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule, PublicAccessPanelComponent], - providers: [ - { provide: UsersService, useValue: mockUsersService }, - { provide: TablesService, useValue: mockTablesService }, - { provide: ConnectionsService, useValue: mockConnectionsService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PublicAccessPanelComponent); - component = fixture.componentInstance; - testable = component as PublicAccessPanelTestable; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should opt in to loading public permissions for this connection on init', () => { - expect(mockUsersService.loadPublicPermissions).toHaveBeenCalledWith(CONNECTION_ID); - }); - - it('should load the connection tables and fall back to a normalized display name', () => { - expect(mockTablesService.fetchTables).toHaveBeenCalledWith(CONNECTION_ID); - expect(testable.tablesLoading()).toBe(false); - expect(testable.tables()).toEqual([ - { tableName: 'customers', displayName: 'Customers' }, - { tableName: 'orders', displayName: 'Orders' }, - ]); - }); - - it('should report disabled status when nothing is selected', () => { - expect(testable.selectedCount()).toBe(0); - expect(testable.statusLabel()).toBe('Disabled'); - expect(testable.canDisable()).toBe(false); - }); - - it('should seed selection and columns from the stored permissions', () => { - publicPermissions.set({ - enabled: true, - tables: [{ tableName: 'customers', readableColumns: ['id', 'name'] }, { tableName: 'orders' }], - }); - fixture.detectChanges(); - - expect(component.isSelected('customers')).toBe(true); - expect(component.selectedColumns('customers')).toEqual(['id', 'name']); - expect(component.isSelected('orders')).toBe(true); - expect(component.selectedColumns('orders')).toEqual([]); - expect(testable.statusLabel()).toBe('2 tables'); - expect(testable.canDisable()).toBe(true); - }); - - it('should preload columns for every stored table, restricted or not', () => { - publicPermissions.set({ - enabled: true, - tables: [{ tableName: 'customers', readableColumns: ['id'] }, { tableName: 'orders' }], - }); - fixture.detectChanges(); - - // An unrestricted table still renders the column picker, so it needs its options too. - expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers'); - expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'orders'); - expect(component.availableColumns('orders')).toEqual(['id', 'name', 'secret']); - }); - - it('should singularize the status label for one table', () => { - publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] }); - fixture.detectChanges(); - - expect(testable.statusLabel()).toBe('1 table'); - }); - - it('should fetch columns lazily when a table is checked', () => { - component.toggleTable('customers', true); - - expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers'); - expect(component.availableColumns('customers')).toEqual(['id', 'name', 'secret']); - expect(component.isLoadingColumns('customers')).toBe(false); - }); - - it('should not refetch columns for a table already loaded', () => { - component.toggleTable('customers', true); - component.toggleTable('customers', false); - component.toggleTable('customers', true); - - expect(mockTablesService.fetchTableStructure).toHaveBeenCalledTimes(1); - }); - - it('should save an empty column selection as undefined readableColumns', async () => { - component.toggleTable('customers', true); - await component.save(); - - expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ - { tableName: 'customers', readableColumns: undefined }, - ]); - }); - - it('should save an explicit column whitelist', async () => { - component.toggleTable('customers', true); - component.setColumns('customers', ['id', 'name']); - await component.save(); - - expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ - { tableName: 'customers', readableColumns: ['id', 'name'] }, - ]); - }); - - it('should drop a table from the payload when unchecked', async () => { - component.toggleTable('customers', true); - component.toggleTable('orders', true); - component.toggleTable('customers', false); - await component.save(); - - expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ - { tableName: 'orders', readableColumns: undefined }, - ]); - }); - - it('should clear the loading state when the table list fails to load', () => { - mockTablesService.fetchTables = vi.fn().mockReturnValue(throwError(() => new Error('boom'))); - - const failing = TestBed.createComponent(PublicAccessPanelComponent); - failing.detectChanges(); - - expect((failing.componentInstance as PublicAccessPanelTestable).tablesLoading()).toBe(false); - }); - - it('should block editing while a save is in flight', async () => { - let resolveSave: () => void = () => {}; - mockUsersService.savePublicPermissions = vi - .fn() - .mockReturnValue(new Promise((resolve) => (resolveSave = resolve))); - - component.toggleTable('customers', true); - const saving = component.save(); - fixture.detectChanges(); - - // The re-seed after reload can only discard an edit made in this window, so it is closed off. - expect((component as PublicAccessPanelTestable).submitting()).toBe(true); - const host: HTMLElement = fixture.nativeElement; - fixture.nativeElement.querySelector('mat-expansion-panel-header').click(); - fixture.detectChanges(); - await fixture.whenStable(); - expect(host.querySelector('mat-checkbox input')?.hasAttribute('disabled')).toBe(true); - - resolveSave(); - await saving; - expect((component as PublicAccessPanelTestable).submitting()).toBe(false); - }); - - it('should render the table list and column picker once expanded', async () => { - publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers', readableColumns: ['id'] }] }); - fixture.detectChanges(); - - // The body lives in an ng-template matExpansionPanelContent, so nothing above renders it. - fixture.nativeElement.querySelector('mat-expansion-panel-header').click(); - fixture.detectChanges(); - await fixture.whenStable(); - - const host: HTMLElement = fixture.nativeElement; - expect(host.querySelector('.public-access-warning')).toBeTruthy(); - expect(host.querySelectorAll('.public-access-table').length).toBe(2); - expect(host.textContent).toContain('Customers'); - expect(host.textContent).toContain('Orders'); - // Only the selected table exposes a column picker. - expect(host.querySelectorAll('.public-access-columns').length).toBe(1); - expect(host.textContent).toContain('Disable public access'); - }); - - it('should disable public access by saving an empty table list', async () => { - publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] }); - fixture.detectChanges(); - - await component.disablePublicAccess(); - - expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, []); - expect(testable.selectedCount()).toBe(0); - }); -}); diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts deleted file mode 100644 index cd237ff45..000000000 --- a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Component, computed, DestroyRef, effect, inject, OnInit, signal, untracked } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import posthog from 'posthog-js'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { PublicTablePermission } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UsersService } from 'src/app/services/users.service'; -import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; - -interface PublicAccessTable { - tableName: string; - displayName: string; -} - -// A selected table maps to its readable-column whitelist. An empty array means "all columns", -// matching the backend contract where an omitted/empty readableColumns grants every column. -type TableSelection = Map; - -@Component({ - selector: 'app-public-access-panel', - imports: [ - MatButtonModule, - MatCheckboxModule, - MatExpansionModule, - MatFormFieldModule, - MatIconModule, - MatSelectModule, - MatTooltipModule, - ContentLoaderComponent, - ], - templateUrl: './public-access-panel.component.html', - styleUrls: ['./public-access-panel.component.css'], -}) -export class PublicAccessPanelComponent implements OnInit { - private _usersService = inject(UsersService); - private _tablesService = inject(TablesService); - private _connections = inject(ConnectionsService); - private _destroyRef = inject(DestroyRef); - - protected posthog = posthog; - protected connectionID: string; - - protected publicPermissions = this._usersService.publicPermissions; - protected loadingPermissions = this._usersService.publicPermissionsLoading; - - protected tables = signal([]); - protected tablesLoading = signal(true); - protected submitting = signal(false); - - protected selection = signal(new Map()); - protected columnsByTable = signal>({}); - protected loadingColumns = signal>(new Set()); - - protected selectedCount = computed(() => this.selection().size); - - protected statusLabel = computed(() => { - const count = this.selectedCount(); - if (count === 0) return 'Disabled'; - return `${count} ${count === 1 ? 'table' : 'tables'}`; - }); - - // Public access is stored server-side as a derived value: a non-empty table list means - // enabled. `Disable public access` is only meaningful once something is actually stored. - protected canDisable = computed(() => this.publicPermissions().enabled); - - constructor() { - // Seed the local editing state whenever the server state (re)loads, so an external change - // to public access is picked up. Editing is blocked while a save is in flight (see the - // submitting() bindings in the template), which is the only window where this re-seed - // could otherwise discard an unsaved edit. - effect(() => { - const stored = this.publicPermissions().tables; - const seeded: TableSelection = new Map(); - for (const table of stored) { - seeded.set(table.tableName, table.readableColumns ?? []); - } - this.selection.set(seeded); - - // Every already-public table needs its columns, not just the restricted ones: an - // unrestricted table still renders the column picker, which would otherwise be empty. - // Kept untracked: _loadColumns both reads and writes the column signals, so tracking it - // would make a finished column fetch re-run this effect and clobber edits made meanwhile. - untracked(() => { - for (const table of stored) { - this._loadColumns(table.tableName); - } - }); - }); - } - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this._usersService.loadPublicPermissions(this.connectionID); - - // fetchTables does not swallow errors, so clear the loading state on failure too — - // otherwise the panel body is stuck on the content loader forever. - this._tablesService - .fetchTables(this.connectionID) - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe({ - next: (tables) => { - this.tables.set( - tables.map((t) => ({ - tableName: t.table, - displayName: t.display_name || normalizeTableName(t.table), - })), - ); - this.tablesLoading.set(false); - }, - error: () => this.tablesLoading.set(false), - }); - } - - isSelected(tableName: string): boolean { - return this.selection().has(tableName); - } - - selectedColumns(tableName: string): string[] { - return this.selection().get(tableName) ?? []; - } - - availableColumns(tableName: string): string[] { - return this.columnsByTable()[tableName] ?? []; - } - - isLoadingColumns(tableName: string): boolean { - return this.loadingColumns().has(tableName); - } - - toggleTable(tableName: string, selected: boolean): void { - this.selection.update((current) => { - const next = new Map(current); - if (selected) { - next.set(tableName, []); - } else { - next.delete(tableName); - } - return next; - }); - if (selected) { - this._loadColumns(tableName); - } - } - - setColumns(tableName: string, columns: string[]): void { - this.selection.update((current) => { - const next = new Map(current); - next.set(tableName, columns); - return next; - }); - } - - async save(): Promise { - this.submitting.set(true); - try { - await this._usersService.savePublicPermissions(this.connectionID, this._buildPayload()); - } finally { - this.submitting.set(false); - } - } - - async disablePublicAccess(): Promise { - this.submitting.set(true); - try { - await this._usersService.savePublicPermissions(this.connectionID, []); - this.selection.set(new Map()); - } finally { - this.submitting.set(false); - } - } - - private _buildPayload(): PublicTablePermission[] { - return [...this.selection().entries()].map(([tableName, columns]) => ({ - tableName, - readableColumns: columns.length ? columns : undefined, - })); - } - - private _loadColumns(tableName: string): void { - if (this.columnsByTable()[tableName] || this.loadingColumns().has(tableName)) return; - - this.loadingColumns.update((current) => new Set(current).add(tableName)); - this._tablesService - .fetchTableStructure(this.connectionID, tableName) - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe({ - next: (res) => { - this.columnsByTable.update((current) => ({ - ...current, - [tableName]: (res?.structure ?? []).map((field: { column_name: string }) => field.column_name), - })); - this._clearLoadingColumn(tableName); - }, - error: () => this._clearLoadingColumn(tableName), - }); - } - - private _clearLoadingColumn(tableName: string): void { - this.loadingColumns.update((current) => { - const next = new Set(current); - next.delete(tableName); - return next; - }); - } -} diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.css b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.css deleted file mode 100644 index f6a52d0ce..000000000 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.css +++ /dev/null @@ -1,24 +0,0 @@ -.mat-mdc-form-field { - width: 100%; -} - -.message-box { - display: flex; - flex-direction: column; - align-items: center; -} - -.message-text { - text-align: center; - margin-bottom: 24px; -} - -.link { - color: var(--color-primaryPalette-500); -} - -@media (prefers-color-scheme: dark) { - .link { - color: var(--color-whitePalette-500); - } -} diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html deleted file mode 100644 index 7501c2261..000000000 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html +++ /dev/null @@ -1,44 +0,0 @@ -

Add user to {{ data.group.title }} group

-
- - @if (data.availableMembers.length) { - - Select user - - @for (member of data.availableMembers; track member.id) { - - @if (member.name) { {{member.name}} ( }{{member.email}}@if (member.name) { ) } - - } - - -

- Add users to the - - Company before assigning them to a group. -

- } - @if (data.availableMembers.length === 0) { -

- All your company members are already in this group. To add a new one first add them to your company. -

- } -
- - @if (data.availableMembers.length) { - - } @else { - - Open Company page - - } - - -
diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts deleted file mode 100644 index f34a693e9..000000000 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { UsersService } from 'src/app/services/users.service'; -import { UserAddDialogComponent } from './user-add-dialog.component'; - -type UserAddTestable = UserAddDialogComponent & { - submitting: ReturnType>; - groupUserEmail: string; - joinGroupUser: () => Promise; -}; - -const fakeMembers = [ - { id: 'user-1', email: 'alice@test.com', name: 'Alice Smith' }, - { id: 'user-2', email: 'bob@test.com', name: 'Bob Jones' }, - { id: 'user-3', email: 'charlie@test.com', name: null }, -]; - -const fakeGroup = { - id: '12345678-abcd-1234-efgh-123456789012', - title: 'Developers', -}; - -describe('UserAddDialogComponent', () => { - let component: UserAddDialogComponent; - let fixture: ComponentFixture; - let mockDialogRef: { close: ReturnType }; - let mockUsersService: Partial; - - function createComponent(dialogData: { availableMembers: typeof fakeMembers; group: typeof fakeGroup }) { - mockDialogRef = { close: vi.fn() }; - mockUsersService = { - addGroupUser: vi.fn().mockResolvedValue(undefined), - }; - - TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - UserAddDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: dialogData }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: UsersService, useValue: mockUsersService }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(UserAddDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - describe('with available members', () => { - beforeEach(() => { - createComponent({ availableMembers: fakeMembers, group: fakeGroup }); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should display group title in dialog header', () => { - const title = fixture.nativeElement.querySelector('[mat-dialog-title]'); - expect(title.textContent).toContain('Developers'); - }); - - it('should render a select dropdown with available members', () => { - const select = fixture.nativeElement.querySelector('mat-select'); - expect(select).toBeTruthy(); - }); - - it('should not show the "all members already in group" message', () => { - const text = fixture.nativeElement.textContent; - expect(text).not.toContain('All your company members are already in this group'); - }); - - it('should show the "Add users to Company" hint', () => { - const text = fixture.nativeElement.textContent; - expect(text).toContain('Add users to the'); - expect(text).toContain('Company'); - }); - - it('should render an Add submit button', () => { - const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); - const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); - expect(addBtn).toBeTruthy(); - expect(addBtn!.type).toBe('submit'); - }); - - it('should disable Add button when no user is selected (form invalid)', async () => { - await fixture.whenStable(); - fixture.detectChanges(); - const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); - const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); - expect(addBtn!.disabled).toBe(true); - }); - - it('should call addGroupUser with correct groupId and email', async () => { - const testable = component as unknown as UserAddTestable; - testable.groupUserEmail = 'alice@test.com'; - - await testable.joinGroupUser(); - - expect(mockUsersService.addGroupUser).toHaveBeenCalledWith( - '12345678-abcd-1234-efgh-123456789012', - 'alice@test.com', - ); - }); - - it('should close dialog on successful submission', async () => { - const testable = component as unknown as UserAddTestable; - testable.groupUserEmail = 'bob@test.com'; - - await testable.joinGroupUser(); - - expect(mockDialogRef.close).toHaveBeenCalled(); - }); - - it('should reset submitting to false after successful submission', async () => { - const testable = component as unknown as UserAddTestable; - testable.groupUserEmail = 'alice@test.com'; - - await testable.joinGroupUser(); - - expect(testable.submitting()).toBe(false); - }); - - it('should reset submitting to false when service throws', async () => { - (mockUsersService.addGroupUser as ReturnType).mockRejectedValueOnce(new Error('Network error')); - const testable = component as unknown as UserAddTestable; - testable.groupUserEmail = 'alice@test.com'; - - await testable.joinGroupUser().catch(() => {}); - - expect(testable.submitting()).toBe(false); - }); - - it('should not close dialog when service throws', async () => { - (mockUsersService.addGroupUser as ReturnType).mockRejectedValueOnce(new Error('Network error')); - const testable = component as unknown as UserAddTestable; - testable.groupUserEmail = 'alice@test.com'; - - await testable.joinGroupUser().catch(() => {}); - - expect(mockDialogRef.close).not.toHaveBeenCalled(); - }); - - it('should not show "Open Company page" link when members are available', () => { - const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); - const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); - expect(companyPageLink).toBeFalsy(); - }); - }); - - describe('with no available members', () => { - beforeEach(() => { - createComponent({ availableMembers: [], group: fakeGroup }); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should show "all members already in group" message', () => { - const text = fixture.nativeElement.textContent; - expect(text).toContain('All your company members are already in this group'); - }); - - it('should not render a select dropdown', () => { - const select = fixture.nativeElement.querySelector('mat-select'); - expect(select).toBeFalsy(); - }); - - it('should not render an Add button', () => { - const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); - const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); - expect(addBtn).toBeFalsy(); - }); - - it('should show "Open Company page" link', () => { - const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); - const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); - expect(companyPageLink).toBeTruthy(); - }); - - it('should link "Open Company page" to /company', () => { - const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); - const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); - expect(companyPageLink?.getAttribute('href')).toBe('/company'); - }); - }); -}); diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts deleted file mode 100644 index c3cc44498..000000000 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, Inject, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { RouterModule } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { UsersService } from 'src/app/services/users.service'; - -@Component({ - selector: 'app-user-add-dialog', - templateUrl: './user-add-dialog.component.html', - styleUrls: ['./user-add-dialog.component.css'], - imports: [MatDialogModule, FormsModule, MatFormFieldModule, MatSelectModule, MatButtonModule, RouterModule], -}) -export class UserAddDialogComponent { - private _usersService = inject(UsersService); - private _angulartics2 = inject(Angulartics2); - private _dialogRef = inject>(MatDialogRef); - - protected submitting = signal(false); - protected groupUserEmail = ''; - - // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type - constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - - async joinGroupUser() { - this.submitting.set(true); - try { - await this._usersService.addGroupUser(this.data.group.id, this.groupUserEmail); - this._dialogRef.close(); - this._angulartics2.eventTrack.next({ - action: 'User groups: user was added to group successfully', - }); - posthog.capture('User groups: user was added to group successfully'); - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.css b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html deleted file mode 100644 index 9972b7b17..000000000 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html +++ /dev/null @@ -1,27 +0,0 @@ -

Confirm users delete

- -

- You are going to delete - @if (data.user.name) { - {{ data.user.name }} - ({{ data.user.email }}) - } @else { - {{ data.user.email }} - } - from {{ data.group.title }} group. -

-
-

Please confirm.

-
- - - - diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts deleted file mode 100644 index e2019cc92..000000000 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { UsersService } from 'src/app/services/users.service'; -import { UserDeleteDialogComponent } from './user-delete-dialog.component'; - -type UserDeleteTestable = UserDeleteDialogComponent & { - submitting: ReturnType>; -}; - -describe('UserDeleteDialogComponent', () => { - let component: UserDeleteDialogComponent; - let fixture: ComponentFixture; - - const mockDialogRef = { - close: vi.fn(), - }; - - const mockUsersService: Partial = { - deleteGroupUser: vi.fn().mockResolvedValue(undefined), - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [MatSnackBarModule, UserDeleteDialogComponent], - providers: [ - provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: { user: { email: 'user@test.com' }, group: { id: '12345678-123' } } }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { provide: UsersService, useValue: mockUsersService }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UserDeleteDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should call delete user service', async () => { - const testable = component as unknown as UserDeleteTestable; - - await testable.deleteGroupUser(); - - expect(mockUsersService.deleteGroupUser).toHaveBeenCalledWith('user@test.com', '12345678-123'); - expect(testable.submitting()).toBe(false); - }); -}); diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts deleted file mode 100644 index 115dfe139..000000000 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { UsersService } from 'src/app/services/users.service'; -import { UserDeleteDialogComponent } from './user-delete-dialog.component'; - -const mockUsersService: Partial = { - deleteGroupUser: () => Promise.resolve(), -}; - -const meta: Meta = { - title: 'Dialogs/UserDelete', - component: UserDeleteDialogComponent, - decorators: [ - applicationConfig({ - providers: [ - { provide: MatDialogRef, useValue: { close: () => {} } }, - { - provide: MAT_DIALOG_DATA, - useValue: { - user: { email: 'user@example.com' }, - group: { id: 'group-1', title: 'Admins' }, - }, - }, - { provide: UsersService, useValue: mockUsersService }, - ], - }), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts deleted file mode 100644 index fab1502a8..000000000 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, Inject, inject, signal } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { UsersService } from 'src/app/services/users.service'; - -@Component({ - selector: 'app-user-delete-dialog', - templateUrl: './user-delete-dialog.component.html', - styleUrls: ['./user-delete-dialog.component.css'], - imports: [MatButtonModule, MatDialogModule], -}) -export class UserDeleteDialogComponent { - private _usersService = inject(UsersService); - protected dialogRef = inject>(MatDialogRef); - - protected submitting = signal(false); - - // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type - constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - - async deleteGroupUser() { - this.submitting.set(true); - try { - await this._usersService.deleteGroupUser(this.data.user.email, this.data.group.id); - this.dialogRef.close(); - } finally { - this.submitting.set(false); - } - } -} diff --git a/frontend/src/app/components/users/users.component.css b/frontend/src/app/components/users/users.component.css deleted file mode 100644 index 625bbec2a..000000000 --- a/frontend/src/app/components/users/users.component.css +++ /dev/null @@ -1,169 +0,0 @@ -.wrapper { - margin: 0 auto; - width: clamp(18.75em, 96%, 42em); -} - -header { - display: flex; - align-items: center; - justify-content: space-between; - margin: 2em 0 1em; -} - -.mat-h1 { - margin: 0; -} - -.expansion-panel_admin { - margin-bottom: 20px; -} - -.add-group-button .mat-icon { - font-size: 2em; -} - -::ng-deep .mat-expansion-panel-header { - --mat-expansion-header-collapsed-state-height: auto; - --mat-expansion-header-expanded-state-height: auto; - height: auto !important; - min-height: 48px; - padding-top: 12px !important; - padding-bottom: 12px !important; -} - -.mat-expansion-panel-header-title { - align-items: center; - flex-wrap: wrap; - row-gap: 0; - line-height: 1.3; -} - -.title-edit-button { - visibility: hidden; - color: var(--mat-expansion-header-description-color); - margin-left: 12px; -} - -.group-actions { - visibility: hidden; - display: flex; - flex-grow: 0 !important; - justify-content: flex-end; - align-items: center; -} - -.group-item:hover .title-edit-button, -.mat-expanded .title-edit-button { - visibility: visible; -} - -.group-item:hover .group-actions, -.mat-expanded .group-actions { - visibility: visible; -} - -.group-system-badge { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 2px 6px; - border-radius: 10px; - background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 88%); - color: var(--color-alternativePalette-500); - margin-left: 8px; -} - -/* ── Members preview ── */ - -.group-members-preview { - display: flex; - align-items: center; - gap: 0; - flex-basis: 100%; - margin-top: -2px; -} - -.group-avatar { - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--color-accentedPalette-100); - color: var(--color-accentedPalette-500); - font-size: 9px; - font-weight: 600; - display: flex; - align-items: center; - justify-content: center; - margin-left: -3px; - border: 1.5px solid var(--mat-expansion-container-background-color, #fff); - flex-shrink: 0; -} - -.group-avatar:first-child { - margin-left: 0; -} - -@media (prefers-color-scheme: dark) { - .group-avatar { - background: var(--color-accentedPalette-700); - color: var(--color-accentedPalette-100); - border-color: var(--mat-expansion-container-background-color, #1e1e1e); - } -} - -.user { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 0; -} - -.user__label { - display: flex; - flex-direction: column; -} - -.user__email { - font-size: 12px; - color: rgba(0, 0, 0, 0.64); -} - -@media (prefers-color-scheme: dark) { - .user__email { - color: rgba(255, 255, 255, 0.64); - } -} - -.group-members-count { - font-size: 11px; - opacity: 0.45; - margin-left: 6px; - white-space: nowrap; -} - -.no-access { - margin-top: 32px !important; -} - -@media (width <= 600px) { - .wrapper { - width: 100%; - padding: 0 16px; - box-sizing: border-box; - } - - header { - flex-wrap: wrap; - gap: 8px; - margin: 16px 0 12px; - } - - .add-group-button { - flex: 0 0 auto; - } - - .mat-h1 { - font-size: 22px; - } -} diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html deleted file mode 100644 index 056ce8714..000000000 --- a/frontend/src/app/components/users/users.component.html +++ /dev/null @@ -1,144 +0,0 @@ -
-
-

User groups

- @if (canCreateGroup()) { - - } -
- - @if (groups() === null) { - - } - - @if (canEditConnection()) { - - } - - @if (groups(); as groupsList) { - - @for (groupItem of groupsList; track groupItem.group.id) { - @let canManage = canManageGroup(groupItem.group.id); - - - - {{ groupItem.group.title }} - @if (groupItem.group.title === 'Admin') { - system - } - @if (canManage() && groupItem.group.title !== 'Admin') { - - } - @if (getGroupUsers(groupItem.group.id); as groupUsersList) { - - @for (user of groupUsersList.slice(0, 3); track user.email) { - - {{ getUserInitials(user) }} - - } - {{ groupUsersList.length }} {{ groupUsersList.length === 1 ? 'member' : 'members' }} - - } - - - - visibility - - @if (canManage() && groupItem.group.title !== 'Admin') { - - - } - @if (canManage()) { - - } - - - - @if (groupUsers()[groupItem.group.id] === undefined) { - - } - @if (groupUsers()[groupItem.group.id] === 'empty') { -

No users in the group

- } -
    - @if (getGroupUsers(groupItem.group.id); as usersList) { - @for (user of usersList; track user.email) { -
  • -
    - @if (user.name) { - {{user.name}} - - } @else { - {{user.email}} - } -
    - - @if (currentUser()?.email !== user.email && canManage()) { - - } -
  • - } - } -
-
- } -
- } - - @if (companyMembersWithoutAccess().length > 0) { -
- Company members who do NOT have access to this connection: - @for (member of companyMembersWithoutAccess(); track member.id; let last = $last) { - - {{member.name}} ({{member.email}}) {{!last ? ', ' : ''}} - - } -
- } -
diff --git a/frontend/src/app/components/users/users.component.spec.ts b/frontend/src/app/components/users/users.component.spec.ts deleted file mode 100644 index 7e828d5b7..000000000 --- a/frontend/src/app/components/users/users.component.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; -import { PublicPermissions } from 'src/app/models/user'; -import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; -import { TablesService } from 'src/app/services/tables.service'; -import { UsersService } from 'src/app/services/users.service'; -import { GroupAddDialogComponent } from './group-add-dialog/group-add-dialog.component'; -import { GroupDeleteDialogComponent } from './group-delete-dialog/group-delete-dialog.component'; -import { UserAddDialogComponent } from './user-add-dialog/user-add-dialog.component'; -import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dialog.component'; -import { UsersComponent } from './users.component'; - -describe('UsersComponent', () => { - let component: UsersComponent; - let fixture: ComponentFixture; - let dialog: MatDialog; - - const fakeGroup = { - id: 'a9a97cf1-cb2f-454b-a74e-0075dd07ad92', - title: 'Admin', - isMain: true, - }; - - const mockGroups = signal([ - { - group: { - id: 'a9a97cf1-cb2f-454b-a74e-0075dd07ad92', - title: 'Admin', - isMain: true, - }, - accessLevel: 'edit', - }, - { - group: { - id: '77154868-eaf0-4a53-9693-0470182d0971', - title: 'Sellers', - isMain: false, - }, - accessLevel: 'edit', - }, - ]); - - const mockUsersService: Partial = { - groups: mockGroups.asReadonly() as any, - groupsLoading: signal(false).asReadonly() as any, - groupUsers: signal({}).asReadonly() as any, - groupsUpdated: signal('').asReadonly() as any, - setActiveConnection: vi.fn(), - refreshGroups: vi.fn(), - clearGroupsUpdated: vi.fn(), - fetchGroupUsers: vi.fn().mockResolvedValue([]), - fetchAllGroupUsers: vi.fn().mockResolvedValue(undefined), - fetchConnectionUsers: vi.fn(), - publicPermissions: signal({ enabled: false, tables: [] }).asReadonly(), - publicPermissionsLoading: signal(false).asReadonly(), - loadPublicPermissions: vi.fn(), - savePublicPermissions: vi.fn().mockResolvedValue(undefined), - }; - - const mockTablesService: Partial = { - fetchTables: vi.fn().mockReturnValue(of([])), - fetchTableStructure: vi.fn().mockReturnValue(of({ structure: [] })), - }; - - const mockPermissions: Partial = { - canI: () => signal(true).asReadonly(), - ready: signal(true).asReadonly(), - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot(), UsersComponent], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MatDialogRef, useValue: {} }, - { provide: UsersService, useValue: mockUsersService }, - { provide: TablesService, useValue: mockTablesService }, - { provide: CedarPermissionService, useValue: mockPermissions }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UsersComponent); - component = fixture.componentInstance; - dialog = TestBed.inject(MatDialog); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should open create group dialog', () => { - const fakeCreateUsersGroupOpen = vi.spyOn(dialog, 'open'); - const event = { preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() } as unknown as Event; - - component.openCreateUsersGroupDialog(event); - expect(fakeCreateUsersGroupOpen).toHaveBeenCalledWith(GroupAddDialogComponent, { - width: '25em', - }); - }); - - it('should open add user dialog', () => { - const fakeAddUserDialogOpen = vi.spyOn(dialog, 'open'); - - component.openAddUserDialog(fakeGroup); - expect(fakeAddUserDialogOpen).toHaveBeenCalledWith(UserAddDialogComponent, { - width: '25em', - data: { group: fakeGroup, availableMembers: [] }, - }); - }); - - it('should open delete group dialog', () => { - const fakeDeleteGroupDialogOpen = vi.spyOn(dialog, 'open'); - - component.openDeleteGroupDialog(fakeGroup); - expect(fakeDeleteGroupDialogOpen).toHaveBeenCalledWith(GroupDeleteDialogComponent, { - width: '25em', - data: fakeGroup, - }); - }); - - it('should open delete user dialog', () => { - const fakeUser = { - id: 'user-12345678', - createdAt: '2021-10-01T13:43:02.034Z', - gclid: null, - isActive: true, - email: 'user@test.com', - }; - - const fakeDeleteUserDialogOpen = vi.spyOn(dialog, 'open'); - - component.openDeleteUserDialog(fakeUser, fakeGroup); - expect(fakeDeleteUserDialogOpen).toHaveBeenCalledWith(UserDeleteDialogComponent, { - width: '25em', - data: { user: fakeUser, group: fakeGroup }, - }); - }); - - it('should return null for group users that are not loaded', () => { - const result = component.getGroupUsers('nonexistent-id'); - expect(result).toBeNull(); - }); -}); diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts deleted file mode 100644 index dee6f6a13..000000000 --- a/frontend/src/app/components/users/users.component.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatAccordion, MatExpansionModule } from '@angular/material/expansion'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Title } from '@angular/platform-browser'; -import { RouterLink } from '@angular/router'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { differenceBy } from 'lodash-es'; -import posthog from 'posthog-js'; -import { take } from 'rxjs'; -import { GroupUser, User, UserGroup } from 'src/app/models/user'; -import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; -import { CompanyService } from 'src/app/services/company.service'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UserService } from 'src/app/services/user.service'; -import { UsersService } from '../../services/users.service'; -import { PlaceholderUserGroupComponent } from '../skeletons/placeholder-user-group/placeholder-user-group.component'; -import { PlaceholderUserGroupsComponent } from '../skeletons/placeholder-user-groups/placeholder-user-groups.component'; -import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog/cedar-policy-editor-dialog.component'; -import { GroupAddDialogComponent } from './group-add-dialog/group-add-dialog.component'; -import { GroupDeleteDialogComponent } from './group-delete-dialog/group-delete-dialog.component'; -import { GroupNameEditDialogComponent } from './group-name-edit-dialog/group-name-edit-dialog.component'; -import { PublicAccessPanelComponent } from './public-access-panel/public-access-panel.component'; -import { UserAddDialogComponent } from './user-add-dialog/user-add-dialog.component'; -import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dialog.component'; - -@Component({ - selector: 'app-users', - imports: [ - MatButtonModule, - MatIconModule, - MatListModule, - MatExpansionModule, - MatAccordion, - MatTooltipModule, - RouterLink, - Angulartics2OnModule, - PlaceholderUserGroupsComponent, - PlaceholderUserGroupComponent, - PublicAccessPanelComponent, - ], - templateUrl: './users.component.html', - styleUrls: ['./users.component.css'], -}) -export class UsersComponent implements OnInit { - private _usersService = inject(UsersService); - private _userService = inject(UserService); - private _connections = inject(ConnectionsService); - private _company = inject(CompanyService); - private _dialog = inject(MatDialog); - private _title = inject(Title); - private _destroyRef = inject(DestroyRef); - private _permissions = inject(CedarPermissionService); - - protected posthog = posthog; - protected connectionID: string | null = null; - - protected currentUser = signal(null); - // biome-ignore lint/suspicious/noExplicitAny: legacy company member type - protected companyMembers = signal([]); - - protected groups = computed(() => { - const g = this._usersService.groups(); - return g.length > 0 || !this._usersService.groupsLoading() ? g : null; - }); - - protected groupUsers = this._usersService.groupUsers; - - protected companyMembersWithoutAccess = computed(() => { - const members = this.companyMembers(); - const users = this.groupUsers(); - const allGroupUsers = Object.values(users).flat(); - return differenceBy(members, allGroupUsers, 'email'); - }); - - constructor() { - // React to group update events - effect(() => { - const action = this._usersService.groupsUpdated(); - if (!action) return; - - if (action === 'user-added' || action === 'user-deleted') { - // Refresh all group users to get updated data - const currentGroups = this._usersService.groups(); - if (currentGroups.length) { - this._usersService.fetchAllGroupUsers(currentGroups); - } - } else if (action !== 'policy-saved') { - // Group-level changes: refresh groups resource, then users - // (policy-saved already refreshes in UsersService.saveCedarPolicy) - this._usersService.refreshGroups(); - } - this._usersService.clearGroupsUpdated(); - }); - - // Fetch group users when groups load - effect(() => { - const groups = this._usersService.groups(); - if (groups.length > 0) { - this._usersService.fetchAllGroupUsers(groups); - } - }); - } - - ngOnInit() { - this.connectionID = this._connections.currentConnectionID; - - this._connections - .getCurrentConnectionTitle() - .pipe(take(1)) - .subscribe((connectionTitle) => { - this._title.setTitle(`User permissions - ${connectionTitle} | Rocketadmin`); - }); - - this._userService.cast.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((user) => { - this.currentUser.set(user); - - this._company.fetchCompanyMembers(user.company.id).subscribe((members) => { - this.companyMembers.set(members); - }); - }); - } - - protected canCreateGroup = this._permissions.canI('group:edit', 'Group', this._connections.currentConnectionID); - - // Matches ConnectionEditGuard on the public-permissions endpoints. - protected canEditConnection = this._permissions.canI( - 'connection:edit', - 'Connection', - this._connections.currentConnectionID, - ); - - canManageGroup(groupId: string) { - return this._permissions.canI('group:edit', 'Group', groupId); - } - - getGroupUsers(groupId: string): GroupUser[] | null { - const val = this.groupUsers()[groupId]; - if (!val || val === 'empty') return null; - return val; - } - - getUserInitials(user: GroupUser): string { - const name = user.name; - if (name) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); - return parts[0][0].toUpperCase(); - } - return user.email[0].toUpperCase(); - } - - openCreateUsersGroupDialog(event: Event) { - event.preventDefault(); - event.stopImmediatePropagation(); - const dialogRef = this._dialog.open(GroupAddDialogComponent, { - width: '25em', - }); - dialogRef.afterClosed().subscribe((createdGroup) => { - if (createdGroup) { - this._dialog.open(CedarPolicyEditorDialogComponent, { - width: '40em', - panelClass: 'mobile-bottom-sheet-dialog', - data: { groupId: createdGroup.id, groupTitle: createdGroup.title, cedarPolicy: null }, - }); - } - }); - } - - openAddUserDialog(group: UserGroup) { - const groupUsersList = this.groupUsers()[group.id]; - const availableMembers = differenceBy( - this.companyMembers(), - groupUsersList === 'empty' ? [] : ((groupUsersList as []) ?? []), - 'email', - ); - this._dialog.open(UserAddDialogComponent, { - width: '25em', - data: { availableMembers, group }, - }); - } - - openDeleteGroupDialog(group: UserGroup) { - this._dialog.open(GroupDeleteDialogComponent, { - width: '25em', - data: group, - }); - } - - openEditGroupNameDialog(e: Event, group: UserGroup) { - e.stopPropagation(); - this._dialog.open(GroupNameEditDialogComponent, { - width: '25em', - data: group, - }); - } - - openCedarPolicyDialog(group: UserGroup) { - this._dialog.open(CedarPolicyEditorDialogComponent, { - width: '40em', - panelClass: 'mobile-bottom-sheet-dialog', - data: { groupId: group.id, groupTitle: group.title, cedarPolicy: group.cedarPolicy }, - }); - } - - openDeleteUserDialog(user: GroupUser, group: UserGroup) { - this._dialog.open(UserDeleteDialogComponent, { - width: '25em', - data: { user, group }, - }); - } -} diff --git a/frontend/src/app/components/view-as-banner/view-as-banner.component.css b/frontend/src/app/components/view-as-banner/view-as-banner.component.css deleted file mode 100644 index 476666cd2..000000000 --- a/frontend/src/app/components/view-as-banner/view-as-banner.component.css +++ /dev/null @@ -1,40 +0,0 @@ -.view-as-banner { - position: sticky; - top: 0; - z-index: 1100; - display: flex; - align-items: center; - gap: 12px; - padding: 8px 16px; - background-color: var(--color-warningPalette-500); - color: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); - font-size: 14px; -} - -.view-as-banner__icon { - flex-shrink: 0; - font-size: 20px; - width: 20px; - height: 20px; - line-height: 20px; -} - -.view-as-banner__text { - flex: 1; - min-width: 0; -} - -.view-as-banner__text strong { - font-weight: 600; -} - -.view-as-banner__exit.mat-mdc-outlined-button { - --mdc-outlined-button-label-text-color: #fff; - --mdc-outlined-button-outline-color: rgba(255, 255, 255, 0.7); - flex-shrink: 0; -} - -.view-as-banner__exit.mat-mdc-outlined-button:hover { - background-color: rgba(255, 255, 255, 0.12); -} diff --git a/frontend/src/app/components/view-as-banner/view-as-banner.component.html b/frontend/src/app/components/view-as-banner/view-as-banner.component.html deleted file mode 100644 index 8e0086f19..000000000 --- a/frontend/src/app/components/view-as-banner/view-as-banner.component.html +++ /dev/null @@ -1,11 +0,0 @@ -@if (isActive()) { -
- visibility - - Viewing as group {{ groupLabel() }} - - -
-} diff --git a/frontend/src/app/components/view-as-banner/view-as-banner.component.ts b/frontend/src/app/components/view-as-banner/view-as-banner.component.ts deleted file mode 100644 index f227a6033..000000000 --- a/frontend/src/app/components/view-as-banner/view-as-banner.component.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { UsersService } from '../../services/users.service'; -import { ViewAsService } from '../../services/view-as.service'; - -@Component({ - selector: 'app-view-as-banner', - imports: [MatButtonModule, MatIconModule], - templateUrl: './view-as-banner.component.html', - styleUrls: ['./view-as-banner.component.css'], -}) -export class ViewAsBannerComponent { - private _viewAs = inject(ViewAsService); - private _usersService = inject(UsersService); - - protected isActive = this._viewAs.isActive; - - protected groupLabel = computed(() => { - const id = this._viewAs.viewAsGroupId(); - if (!id) return ''; - const match = this._usersService.groups().find((gi) => gi.group.id === id); - if (match) return match.group.title; - // Group list not loaded for this connection — fall back to a truncated id - return id.length > 8 ? `${id.slice(0, 8)}…` : id; - }); - - exit(): void { - this._viewAs.clearViewAs(); - } -} diff --git a/frontend/src/app/components/zapier/zapier.component.css b/frontend/src/app/components/zapier/zapier.component.css deleted file mode 100644 index 0921729bc..000000000 --- a/frontend/src/app/components/zapier/zapier.component.css +++ /dev/null @@ -1,29 +0,0 @@ -.profile-layout { - display: flex; - height: calc(100vh - 64px); -} - -.profile-main { - flex: 1; - background-color: var(--mat-sidenav-content-background-color); - overflow-y: auto; -} - -::ng-deep .profile-main > app-alert { - position: relative; - top: 0; - margin: 24px; -} - -.zapier-page { - display: flex; - flex-direction: column; - margin: var(--top-margin) auto; - padding: 0 clamp(40px, 5vw, 100px); - max-width: 1200px; -} - -.zapier-docs-button { - align-self: flex-end; - margin-bottom: -36px; -} diff --git a/frontend/src/app/components/zapier/zapier.component.html b/frontend/src/app/components/zapier/zapier.component.html deleted file mode 100644 index 9554a21b0..000000000 --- a/frontend/src/app/components/zapier/zapier.component.html +++ /dev/null @@ -1,27 +0,0 @@ -
- - -
- - -
- - open_in_new - Docs - - @if (currentUser()) { - - } -
-
-
diff --git a/frontend/src/app/components/zapier/zapier.component.spec.ts b/frontend/src/app/components/zapier/zapier.component.spec.ts deleted file mode 100644 index d4f23678c..000000000 --- a/frontend/src/app/components/zapier/zapier.component.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterModule } from '@angular/router'; -import { ZapierComponent } from './zapier.component'; - -describe('ZapierComponent', () => { - let component: ZapierComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ZapierComponent, RouterModule.forRoot([])], - providers: [provideHttpClient()], - }).compileComponents(); - - fixture = TestBed.createComponent(ZapierComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/components/zapier/zapier.component.ts b/frontend/src/app/components/zapier/zapier.component.ts deleted file mode 100644 index d7b6bbdad..000000000 --- a/frontend/src/app/components/zapier/zapier.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; - -import { UserService } from 'src/app/services/user.service'; -import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; -import { AlertComponent } from '../ui-components/alert/alert.component'; - -@Component({ - selector: 'app-zapier', - imports: [MatIconModule, MatButtonModule, ProfileSidebarComponent, AlertComponent], - templateUrl: './zapier.component.html', - styleUrl: './zapier.component.css', - schemas: [CUSTOM_ELEMENTS_SCHEMA], -}) -export class ZapierComponent implements OnInit { - currentUser = toSignal(inject(UserService).cast); - - ngOnInit(): void { - this._loadZapierElements(); - } - - private _loadZapierElements(): void { - const cdnBase = 'https://cdn.zapier.com/packages/partner-sdk/v0/zapier-elements'; - - if (!document.querySelector(`script[src="${cdnBase}/zapier-elements.esm.js"]`)) { - const script = document.createElement('script'); - script.type = 'module'; - script.src = `${cdnBase}/zapier-elements.esm.js`; - document.head.appendChild(script); - } - - if (!document.querySelector(`link[href="${cdnBase}/zapier-elements.css"]`)) { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = `${cdnBase}/zapier-elements.css`; - document.head.appendChild(link); - } - } -} diff --git a/frontend/src/app/consts/acronyms.ts b/frontend/src/app/consts/acronyms.ts deleted file mode 100644 index 990f4c316..000000000 --- a/frontend/src/app/consts/acronyms.ts +++ /dev/null @@ -1,5828 +0,0 @@ -export default [ - '0D', - '1AM', - '1D', - '2AM', - '2D', - '2G', - '2LA', - '2Lt', - '3AM', - '3D', - '3G', - '3i', - '3M', - '4D', - '4G', - '4GL', - '4WD', - '5D', - '6D', - '7D', - '8D', - '9D', - 'A1C', - 'A4', - 'A10', - 'aa', - 'aA', - 'AA', - 'AAA', - 'AAAI', - 'AAAS', - 'AAAV', - 'AAC', - 'AAD', - 'AADD', - 'AADT', - 'AADAC', - 'AADC', - 'AAFC', - 'AAFES', - 'AAI', - 'AAIS', - 'AAM', - 'AAMOF', - 'AAN', - 'AANEAS', - 'AAODL', - 'AAP', - 'AAPC', - 'aar', - 'AAR', - 'AARP', - 'AAS', - 'AASPEM', - 'AAT', - 'AAV', - 'AAWC', - 'ab', - 'AB', - 'ABA', - 'ABB', - 'ABBA', - 'ABC', - 'ABCA', - 'ABCS', - 'ABET', - 'ABFIST', - 'ABGI', - 'ABIN', - 'abk', - 'ABL', - 'ABLE', - 'ABM', - 'ABMC', - 'ABN', - 'ABPI', - 'ABRSM', - 'ABS', - 'ABSA', - 'ABSTRIPS', - 'ABV', - 'ABW', - 'aC', - 'Ac', - 'AC', - 'AC1', - 'AC2', - 'ACA', - 'ACARS', - 'ACAS', - 'ACB', - 'ACC', - 'ACCA', - 'ACCAC', - 'ACCD', - 'ACCS', - 'ACDA', - 'ACDC', - 'ACDT', - 'ACE', - 'ACES', - 'ACF', - 'ACHT', - 'ACID', - 'ACINT', - 'ACK', - 'ACL', - 'ACLJ', - 'ACLOS', - 'ACLS', - 'ACLU', - 'ACM', - 'ACMC', - 'ACN', - 'ACNA', - 'ACO', - 'ACOBA', - 'ACOG', - 'ACOM', - 'ACORN', - 'ACP', - 'ACR', - 'ACRI', - 'ACRT', - 'ACS', - 'ACSC', - 'ACST', - 'ACST', - 'ACT', - 'ACTD', - 'ACTF', - 'ACTU', - 'ACU', - 'ACUF', - 'ACV', - 'AD', - 'ADA', - 'ADAC', - 'ADAGE', - 'ADAP', - 'ADAS', - 'ADAT', - 'ADatP', - 'ADATS', - 'ADB', - 'ADC', - 'ADCTS', - 'ADD', - 'ADDIE', - 'ADDNS', - 'ADF', - 'ADGB', - 'ADHD', - 'ADHS', - 'ADI', - 'ADIZ', - 'ADL', - 'ADM', - 'ADOA', - 'ADP', - 'ADRG', - 'ADS', - 'ADSR', - 'ADT', - 'ADTB', - 'ADW', - 'ae', - 'AE', - 'AED', - 'AEDT', - 'AEF', - 'AEGIS', - 'AESR', - 'AEST', - 'aet', - 'AEW', - 'af', - 'aF', - 'AF', - 'AFA', - 'AFAIC', - 'AFAICT', - 'AFAIK', - 'AFAIR', - 'AFAM', - 'AFATDS', - 'AFC', - 'AFCEA', - 'AFDB', - 'AFES', - 'AFG', - 'AFI', - 'AFIS', - 'AFIST', - 'AFJ', - 'AFK', - 'AFL', - 'AFLAC', - 'AFM', - 'AFN', - 'AFO', - 'AFOSR', - 'AFP', - 'AFQT', - 'afr', - 'AFRC', - 'AFRL', - 'AFROTC', - 'AFRRI', - 'AFRTS', - 'AFS', - 'AFSC', - 'AFSCN', - 'AFSPC', - 'AFT', - 'AFV', - 'AFWA', - 'ag', - 'Ag', - 'AG', - 'AGA', - 'AGARD', - 'AGARDograph', - 'AGASA', - 'AGB', - 'AGC', - 'AGC', - 'AGDC', - 'AGI', - 'AGL', - 'AGM', - 'AGN', - 'AGO', - 'AGP', - 'AGRICOLA', - 'AGS', - 'AGTS', - 'AGU', - 'AGW', - 'aH', - 'Ah', - 'AH', - 'AHA', - 'AHCPR', - 'AHEC', - 'AHPCRC', - 'AHQ', - 'AHRQ', - 'AHS', - 'AI', - 'AIA', - 'AIAA', - 'AIC', - 'AICD', - 'AICMFP', - 'AID', - 'AIDS', - 'AIM', - 'AIMP', - 'AIPAC', - 'AIR', - 'AIRFA', - 'AIRS', - 'AIS', - 'AISB', - 'aJ', - 'AJ', - 'Ajax', - 'AJD', - 'AJP', - 'AJPW', - 'ak', - 'aK', - 'AK', - 'aka', - 'AKA', - 'AKDT', - 'AKST', - 'AKP', - 'aL', - 'Al', - 'AL', - 'ALA', - 'ALAP', - 'ALARM', - 'ALB', - 'ALD', - 'ALERT', - 'ALF', - 'ALG', - 'ALGOL', - 'ALL', - 'ALOA', - 'ALOC', - 'ALPA', - 'ALS', - 'ALSHS', - 'ALSP', - 'ALTE', - 'ALTACS', - 'am', - 'Am', - 'AM', - 'AM1', - 'AMA', - 'AMAPS', - 'AMC', - 'AMCB', - 'AMD', - 'AMF', - 'AMFOM', - 'AMFR', - 'AMFRS', - 'amh', - 'AML', - 'AMLCD', - 'AMLO', - 'AMO', - 'AMOLED', - 'AMOS', - 'AMP', - 'AMRAAM', - 'AMRDEC', - 'AMS', - 'AMSAA', - 'AMSO', - 'AMSU', - 'AMTE', - 'an', - 'aN', - 'AN', - 'ANA', - 'ANC', - 'AND', - 'ANFO', - 'ANG', - 'ANGLiCo', - 'ANL', - 'ANM', - 'ANOVA', - 'ANS', - 'ANSI', - 'ANTM', - 'ANT', - 'ANTY', - 'ANWR', - 'ANZ', - 'ANZAC', - 'AO', - 'AOA', - 'AOC', - 'AOD', - 'AOI', - 'AOL', - 'AOR', - 'AOSTH', - 'AOTS', - 'AOU', - 'AP', - 'APA', - 'APB', - 'APC', - 'APEC', - 'APCNR', - 'APCR', - 'APDS', - 'APEDS', - 'APEX', - 'APFSDS', - 'APHA', - 'APHE', - 'APHIS', - 'API', - 'APL', - 'APM', - 'APOD', - 'APOE', - 'APOLLO', - 'APP', - 'APR', - 'APS', - 'AQ', - 'AQIM', - 'ar', - 'Ar', - 'AR', - 'ara', - 'ARAG', - 'ARB', - 'ARBA', - 'ARC', - 'ARCA', - 'ARCENT', - 'ARDA', - 'ARE', - 'ARF', - 'arg', - 'ARG', - 'ARH', - 'ARI', - 'ARL', - 'ARM', - 'ARNet', - 'ARP', - 'ARPA', - 'ARRC', - 'ARRF', - 'ARRL', - 'ARS', - 'ARSC', - 'ARSPACE', - 'ART', - 'ARTCC', - 'ARWG', - 'as', - 'aS', - 'As', - 'AS', - 'AS', - 'ASA', - 'ASAP', - 'ASARS', - 'ASB', - 'asbl', - 'ASBO', - 'ASCAP', - 'ASCC', - 'ASCIET', - 'ASCII', - 'ASD', - 'ASDIC', - 'ASEAN', - 'ASFAR', - 'ASG', - 'ASIC', - 'ASICS', - 'ASIO', - 'ASIP', - 'ASL', - 'asm', - 'ASM', - 'ASMB', - 'ASME', - 'ASMP', - 'ASN', - 'ASOC', - 'ASP', - 'ASPI', - 'ASR', - 'ASRAAM', - 'ASSC', - 'AST', - 'ASTIA', - 'ASTM', - 'ASTREA', - 'ASUW', - 'ASVAB', - 'ASVEL', - 'ASW', - 'ASWORG', - 'ASX', - 'aT', - 'At', - 'ATA', - 'ATAC', - 'ATACMS', - 'ATAPI', - 'ATAS', - 'ATAT', - 'ATB', - 'ATC', - 'ATCCIS', - 'ATESS', - 'ATF', - 'ATG', - 'ATGM', - 'ATGW', - 'ATI', - 'Atisbo', - 'ATK', - 'ATL', - 'ATLAS', - 'ATM', - 'ATN', - 'ATO', - 'ATP', - 'ATSD', - 'ATSDR', - 'ATV', - 'ATWA', - 'Au', - 'AU', - 'AUC', - 'AUD', - 'AUS', - 'AUSCANNZUKUS', - 'Auslan', - 'AUT', - 'AUTL', - 'av', - 'aV', - 'Av', - 'AV', - 'ava', - 'AVA', - 'AVB', - 'AVDLR', - 'ave', - 'AVGP', - 'AVI', - 'AVIM', - 'AVLB', - 'AVM', - 'AVO', - 'AVP', - 'AVP', - 'AVRE', - 'AVTB', - 'AVUM', - 'AVVO', - 'aW', - 'AW', - 'AWA', - 'AWACS', - 'AWC', - 'AWD', - 'AWE', - 'AWG', - 'AWOL', - 'AWP', - 'AWSIM', - 'AWST', - 'AWW', - 'AX', - 'AXP', - 'ay', - 'AY', - 'AYBABTU', - 'aym', - 'AYU', - 'az', - 'AZ', - 'aze', - 'AZE', - 'AZN', - 'AZUR', - 'BAR', - 'BB', - 'BBB', - 'BBC', - 'BBD', - 'BBL', - 'BBS', - 'BBYO', - 'BC', - 'BCA', - 'BCCI', - 'BCE', - 'BCS', - 'BEA', - 'BES', - 'BH', - 'BHS', - 'BNF', - 'BNP', - 'BO', - 'BoAML', - 'BOC', - 'BOGs', - 'BOI', - 'BOM', - 'BOQ', - 'BOT', - 'BP', - 'BPV', - 'BQ', - 'BR', - 'BRB', - 'BRN', - 'BS', - 'BSA', - 'BSatCoP', - 'BSD', - 'BSE', - 'BSN', - 'BSNL', - 'BSP', - 'BST', - 'Bt', - 'BT', - 'BTN', - 'C2C', - 'Ca', - 'CA', - 'CAA', - 'CAAT', - 'CAC', - 'CACM', - 'CAD', - 'CADCAM', - 'CADPAT', - 'CADMID', - 'CAE', - 'CAEn', - 'CAF', - 'CAFAD', - 'CAFDE', - 'CAI', - 'CAIR', - 'CALABARZON', - 'CalPERS', - 'Caltrans', - 'CAM', - 'CAMRA', - 'CANA', - 'CANDU', - 'Canola', - 'CAP', - 'CAPES', - 'CARE', - 'CARMONETTE', - 'CARP', - 'CART', - 'CAS', - 'CASA', - 'CASE', - 'CASS', - 'CASTFOREM', - 'CASTOR', - 'cat', - 'CAT', - 'CATK', - 'CATS', - 'CATT', - 'CAV', - 'CAVOK', - 'CAW', - 'CB', - 'CBASSE', - 'CBC', - 'CBE', - 'CBF', - 'CBGB', - 'CBLA', - 'CBN', - 'CBM', - 'CBML', - 'CBOL', - 'CBOT', - 'CBR', - 'CBRN', - 'CBRNE', - 'CBS', - 'CBT', - 'CBUS', - 'CC', - 'CCA', - 'CCC', - 'CCCP', - 'CCD', - 'CCF', - 'CCG', - 'CCGC', - 'CCGH', - 'CCGS', - 'CCH', - 'CCIF', - 'CCII', - 'CCIR', - 'CCIT', - 'CCITT', - 'CCK', - 'CCM', - 'CCOC', - 'CCOP', - 'CCP', - 'CCPA', - 'CCR', - 'CCRAp', - 'CCRP', - 'CCRTS', - 'CCSCS', - 'CCSCS', - 'CCSIL', - 'CCSK', - 'CCT', - 'CCTT', - 'cd', - 'Cd', - 'CD', - 'CD1', - 'CDA', - 'CDC', - 'CDD', - 'CDE', - 'CDEC', - 'CDF', - 'CDIAC', - 'CDipAF', - 'CDL', - 'CDM', - 'CDMA', - 'CDMSII', - 'Cdr', - 'CDS', - 'Cdt', - 'CDT', - 'ce', - 'Ce', - 'CE', - 'CEA', - 'CECOM', - 'CEDEX', - 'CEGEP', - 'CEI', - 'CENTCOM', - 'CENTO', - 'CENZUB', - 'CEO', - 'CEP', - 'CEPT', - 'CERCLA', - 'CERDEC', - 'CERGA', - 'CERN', - 'CERT', - 'ces', - 'CESS', - 'CEV', - 'CEWC', - 'Cf', - 'CF', - 'CFA', - 'CFAR', - 'CFC', - 'CFES', - 'CFFZ', - 'CFG', - 'CFHT', - 'CFI', - 'CFL', - 'CFLCC', - 'CFM', - 'CFO', - 'CFP', - 'CFR', - 'CFRP', - 'CFS', - 'CFSP', - 'CFU', - 'CFV', - 'CFZ', - 'CG', - 'CGDC', - 'CGF', - 'CGH', - 'CGIG', - 'CGM', - 'CGO', - 'CGS', - 'CGT', - 'ch', - 'ch', - 'CH', - 'cha', - 'CHAMPUS', - 'CHAOS', - 'CHAPS', - 'CHDK', - 'che', - 'CHE', - 'CHF', - 'CHI', - 'CHI', - 'CHiP', - 'CHIPS', - 'CHL', - 'CHLA', - 'CHN', - 'CHOGM', - 'CHP', - 'CHPSO', - 'CHS', - 'CHT', - 'chu', - 'chv', - 'CREN', - 'Ci', - 'CI', - 'CIA', - 'CIAO', - 'CIB', - 'CIBC', - 'CICS', - 'CID', - 'CID', - 'CIEF', - 'CIF', - 'CIMIC', - 'CIMMYT', - 'CINC', - 'CINEOS', - 'CIO', - 'CIP', - 'CIPE', - 'CIR', - 'CIR', - 'CIS', - 'CISA', - 'CISB', - 'CISPR', - 'CISSP', - 'CITES', - 'CITS', - 'CIV', - 'CIVETS', - 'CIVPOP', - 'CIWS', - 'CHRS', - 'CJ', - 'CJC', - 'CJD', - 'CJCS', - 'CJO', - 'CJTF', - 'CK', - 'CKA', - 'CKND', - 'Cl', - 'CL', - 'CLA', - 'CLEC', - 'CLEMARS', - 'CLI', - 'CLIC', - 'CLO', - 'CLOB', - 'CLOS', - 'CLOS', - 'CLP', - 'CLPFC', - 'CLRP', - 'Cm', - 'CM', - 'CMA', - 'CMB', - 'CMBR', - 'CME', - 'CMHC', - 'CMIIAW', - 'CMIS', - 'CMJ', - 'CML', - 'CMLL', - 'CMM', - 'CMMC', - 'CMML', - 'CMO', - 'CMOC', - 'CMOS', - 'CMP', - 'CMP', - 'CMPD', - 'CMPD', - 'CMR', - 'CMS', - 'CMT', - 'CMU', - 'CMW', - 'CMYK', - 'CN', - 'CNA', - 'CNBC', - 'CND', - 'CNE', - 'CNF', - 'CNI', - 'CNI', - 'CNN', - 'CNO', - 'CNR', - 'CNV', - 'CNY', - 'co', - 'Co', - 'CO', - 'COA', - 'COB', - 'COBRA', - 'COCOM', - 'COD', - 'CoDA', - 'codec', - 'COE', - 'COEA', - 'COEIA', - 'COFT', - 'COG', - 'COGENT', - 'COIL', - 'COIN', - 'COK', - 'COL', - 'COLA', - 'COLT', - 'COM', - 'COMECON', - 'COMMZ', - 'COMSEC', - 'CONAD', - 'CONCACAF', - 'CONMEBOL', - 'CONOPS', - 'CONUS', - 'COO', - 'COP', - 'COPE', - 'COPUS', - 'cor', - 'CORBA', - 'CORD', - 'CORE', - 'CORG', - 'COROT', - 'cos', - 'COS', - 'COSCOM', - 'COSER', - 'CoT', - 'COTS', - 'Coy', - 'CP', - 'CPA', - 'CPAC', - 'CPCU', - 'CPD', - 'CPE', - 'CPE', - 'CPF', - 'CPG', - 'CPI', - 'CPIM', - 'CPIR', - 'CPON', - 'CPPCC', - 'CPR', - 'CPR', - 'CPSM', - 'CPSU', - 'CPT', - 'CPU', - 'CPV', - 'CPX', - 'CQ', - 'CQD', - 'cr', - 'Cr', - 'CR', - 'CRAM', - 'CRC', - 'CRD', - 'cre', - 'CREB', - 'CREEP', - 'CRI', - 'CRIPL', - 'CRM', - 'CRO', - 'CRP', - 'CRT', - 'CRTC', - 'cs', - 'Cs', - 'CS', - 'CSA', - 'CSAR', - 'CSC', - 'CSEAL', - 'CSERIAC', - 'CSG', - 'CSI', - 'CSICOP', - 'CSIP', - 'CSIS', - 'CSK', - 'CSL', - 'CSPI', - 'CSR', - 'CSR', - 'CSRC', - 'CSRF', - 'CSS', - 'CSSCS', - 'CST', - 'CT', - 'CTA', - 'CTBT', - 'CTC', - 'CTDB', - 'CTE', - 'CTGF', - 'CTI', - 'CTIL', - 'CTIS', - 'CTMS', - 'CTO', - 'CTS', - 'CTSS', - 'CTU', - 'CTVT', - 'cu', - 'Cu', - 'CU', - 'CUB', - 'CUCV', - 'CUNY', - 'CUP', - 'CUPE', - 'CUPS', - 'CUPW', - 'CUREA', - 'CuW', - 'CUW', - 'cv', - 'CV', - 'CVCC', - 'CVE', - 'CVI', - 'CVLL', - 'CVR', - 'CVS', - 'CVSD', - 'CVT', - 'CW', - 'CWC', - 'CWS', - 'CWRU', - 'CX', - 'CXR', - 'cy', - 'CY', - 'cym', - 'CYM', - 'CYP', - 'CZ', - 'CZE', - 'CZK', - 'CZF', - 'CZMA', - 'd', - 'D', - 'da', - 'DA', - 'DAB', - 'DACT', - 'DADVSI', - 'DAES', - 'DAF', - 'DAFIF', - 'DAK', - 'dan', - 'DAO', - 'DAQ', - 'DARE', - 'DARO', - 'DARPA', - 'DART', - 'DAS', - 'DASD', - 'DASL', - 'DAU', - 'DAWN', - 'dB', - 'Db', - 'DB', - 'DBA', - 'DBBL', - 'DBCS', - 'DBDO', - 'DBE', - 'DBF', - 'DBM', - 'DBR', - 'DBRA', - 'DBS', - 'DC', - 'DCA', - 'DCAA', - 'DCAS', - 'DCC', - 'DCC', - 'DCCP', - 'DCD', - 'DCE', - 'DCFC', - 'DCI', - 'DCIM', - 'DCIMS', - 'DCL', - 'DCN', - 'DCOM', - 'DCOP', - 'DCSOPS', - 'DCW', - 'DCYSC', - 'DD', - 'DDA', - 'DDD', - 'DDL', - 'DDN', - 'DDoS', - 'DDP', - 'DDR', - 'DDS', - 'DDT', - 'de', - 'DE', - 'DEA', - 'DEA', - 'DEC', - 'DeCA', - 'DECSIM', - 'DECT', - 'DEFCON', - 'DEFRA', - 'DELT', - 'DEM', - 'DEN', - 'DEPSECDEF', - 'DERA', - 'DES', - 'deu', - 'DEU', - 'DF', - 'DFAD', - 'DFAS', - 'DFB', - 'DFCB', - 'DfID', - 'DFL', - 'DFM', - 'DFM', - 'DFSP', - 'DfT', - 'DFTBA', - 'DG', - 'DGA', - 'DGPS', - 'DGZ', - 'DH', - 'DHA', - 'DHHS', - 'DHL', - 'DHRA', - 'DHS', - 'DHSS', - 'DHTFYSS', - 'DHY', - 'DIA', - 'DIAC', - 'DiC', - 'DICASS', - 'DILLIGAD', - 'DILLIGAF', - 'DIF', - 'DIFAR', - 'DIME', - 'DIMM', - 'DIN', - 'DINA', - 'DINKY', - 'DIP', - 'DIPS', - 'DIR', - 'DIS', - 'DISA', - 'DISCOM', - 'DISE', - 'DISSTAF', - 'DITY', - 'div', - 'DIVAD', - 'DIY', - 'DJ', - 'DJD', - 'DJF', - 'DJI', - 'DJS', - 'DK', - 'DKK', - 'DL', - 'DLA', - 'DLIC', - 'DLIR', - 'DLL', - 'DLPMC', - 'DLR', - 'DLRP', - 'DLS', - 'DLSA', - 'DLSc', - 'DLSC', - 'DLTBGYD', - 'DM', - 'DMA', - 'DMC', - 'DMCA', - 'DMD', - 'DMI', - 'DMM', - 'DMOC', - 'DMOS', - 'DMPA', - 'DMPFC', - 'DMPK', - 'DMRB', - 'DMS', - 'DMSO', - 'DMSP', - 'DMT', - 'DMU', - 'DMV', - 'DMZ', - 'DN', - 'DNA', - 'DNAW', - 'DND', - 'DNDi', - 'DNDO', - 'DNM', - 'DNK', - 'DNR', - 'DNS', - 'DO', - 'DOA', - 'DOB', - 'DOD', - 'DODAF', - 'DoDD', - 'DoDEA', - 'DOE', - 'DOHC', - 'DOI', - 'DOM', - 'DOMS', - 'DOP', - 'DORA', - 'DOS', - 'DOT', - 'DOTA', - 'DotD', - 'DOTMLPF', - 'DOW', - 'DP', - 'DP', - 'DPA', - 'DPCO', - 'DPDT', - 'DPICM', - 'DPKO', - 'DPMO', - 'DPPDB', - 'DPRE', - 'DPRK', - 'DPST', - 'DQ', - 'DR', - 'DRA', - 'DRAGN', - 'DRAM', - 'DRC', - 'DRDC', - 'DREA', - 'DRES', - 'DRET', - 'DREV', - 'DRI', - 'DRIC', - 'DRL', - 'DRM', - 'DRMS', - 'DRPR', - 'DRT', - 'Ds', - 'DS', - 'DSA', - 'DSAA', - 'DSAS', - 'DSB', - 'DSC', - 'DSCS', - 'DSCSOC', - 'DSD', - 'DSDS', - 'DSE', - 'DSG', - 'DSID', - 'DSL', - 'DSLR', - 'DSM', - 'DSN', - 'DSO', - 'dsp', - 'dspl', - 'dspm', - 'dspml', - 'dspms', - 'dsps', - 'DSP', - 'DSPL', - 'DSR', - 'DSRV', - 'DSS', - 'DST', - 'DSTL', - 'dsvp', - 'DSWA', - 'DTA', - 'DTD', - 'DTE', - 'DTED', - 'DTG', - 'DTH', - 'DTI', - 'DTIC', - 'DTL', - 'DTM', - 'DTMF', - 'DTLOMS', - 'DTO', - 'DTOMLS', - 'DTR', - 'DTRA', - 'DTSA', - 'DU', - 'DUI', - 'DUMBO', - 'DUSD', - 'dv', - 'DV', - 'DVD', - 'DVLC', - 'dvm', - 'DVM', - 'DVO', - 'DVOM', - 'DVP', - 'dvp', - 'dvu', - 'DW', - 'DWG', - 'DWI', - 'DWM', - 'DWIM', - 'DWTS', - 'DWW', - 'DX', - 'DXA', - 'DXM', - 'Dy', - 'DY', - 'dz', - 'DZ', - 'DZA', - 'DZD', - 'dzo', - 'E', - 'EA', - 'EABU', - 'EAC', - 'EAFUS', - 'EAPC', - 'EASA', - 'EAST', - 'EBCDIC', - 'EBD', - 'EBE', - 'EBO', - 'EBSA', - 'EBW', - 'EBW', - 'EC', - 'ECB', - 'ECBR', - 'ECASS', - 'ECCM', - 'ECFS', - 'ECG', - 'ECHO', - 'ECHR', - 'ECJ', - 'ECL', - 'ECM', - 'ECommHR', - 'ECS', - 'ECSC', - 'ECT', - 'ECtHR', - 'ECU', - 'ECW', - 'EDA', - 'EDAC', - 'EDCS', - 'EDD', - 'EDECSIM', - 'EDGAR', - 'EDH', - 'EDS', - 'EDSA', - 'EDT', - 'EDTA', - 'ee', - 'EE', - 'EEC', - 'EEG', - 'EEK', - 'EENT', - 'EEO', - 'EEOC', - 'EEZ', - 'EF', - 'EFF', - 'EFL', - 'EFM', - 'EFOA', - 'EFSA', - 'EFT', - 'EFTA', - 'EFV', - 'EG', - 'EGBUS', - 'EGCG', - 'EGIS', - 'EGP', - 'EGRET', - 'EGY', - 'EH', - 'EHA', - 'EHF', - 'EHR', - 'Ei', - 'EI', - 'EIA', - 'EIAH', - 'EIAH', - 'EIC', - 'EICS', - 'eID', - 'EIRP', - 'EIS', - 'EJ', - 'EK', - 'EKG', - 'EKMS', - 'el', - 'EL', - 'ELF', - 'ELINT', - 'ell', - 'ELL', - 'ELM', - 'ELO', - 'ELP', - 'ELSS', - 'ELT', - 'Em', - 'EM', - 'EMA', - 'Embraer', - 'EMC', - 'EMCON', - 'EMD', - 'EMDG', - 'EMDR', - 'EMEA', - 'EMF', - 'EMG', - 'EMO', - 'EMP', - 'EMS', - 'EMT', - 'EMU', - 'en', - 'EN', - 'ENCODE', - 'ENE', - 'eng', - 'ENG', - 'ENSATT', - 'ENT', - 'ENTEC', - 'ENVL', - 'ENY', - 'eo', - 'EO', - 'EOD', - 'EOG', - 'EOS', - 'EOSAT', - 'EOSDIS', - 'EOTS', - 'EOM', - 'EP', - 'EP', - 'EPA', - 'EPCOT', - 'EPCR', - 'EPFL', - 'EPGS', - 'EPIRB', - 'EPL', - 'EPLRS', - 'epo', - 'EPO', - 'EPOS', - 'EPPA', - 'EPR', - 'EPW', - 'EQ', - 'EQG', - 'Er', - 'ER', - 'ERA', - 'ERCIM', - 'ERI', - 'ERLAWS', - 'ERN', - 'EROS', - 'ERP', - 'ERR', - 'ERS', - 'ERTS', - 'ERV', - 'es', - 'Es', - 'ES', - 'ESA', - 'ESC', - 'ESD', - 'ESDP', - 'ESE', - 'ESH', - 'ESL', - 'ESM', - 'ESO', - 'ESOL', - 'ESP', - 'ESPGHAN', - 'ESPN', - 'est', - 'EST', - 'ESU', - 'ESV', - 'et', - 'ET', - 'Exatesla', - 'ETA', - 'ETB', - 'ETF', - 'ETH', - 'ETI', - 'ETLA', - 'ETM', - 'ETOPS', - 'ETT', - 'eu', - 'Eu', - 'EU', - 'EUCOM', - 'EULA', - 'EUR', - 'eus', - 'EUUSA', - 'EV', - 'EVA', - 'EVIL', - 'EW', - 'ewe', - 'EWR', - 'EWSD', - 'EWTN', - 'EXFOR', - 'EXREP', - 'EYD', - 'EZ', - 'EZLN', - 'f', - 'F', - 'fa', - 'FA', - 'fA', - 'FA', - 'FAA', - 'FAAD', - 'FAADEZ', - 'FAAN', - 'FAAFP', - 'FAAN', - 'FAAQ', - 'FAASV', - 'FAB', - 'FAC', - 'FACA', - 'FACH', - 'FACN', - 'FACP', - 'FACS', - 'FACTS', - 'FAD', - 'FAH', - 'FAI', - 'FAM', - 'fao', - 'FAO', - 'FAPA', - 'FAPHA', - 'FAQ', - 'FARC', - 'FARDC', - 'FARP', - 'fas', - 'FAS', - 'FASCAM', - 'FASD', - 'FASEB', - 'FAT', - 'FAVS', - 'FAW', - 'FB', - 'FBCS', - 'FBI', - 'FBIS', - 'FBIDST', - 'FBM', - 'FBR', - 'FBS', - 'fC', - 'FC', - 'FCA', - 'FCC', - 'FCE', - 'FCF', - 'FCLA', - 'FCM', - 'FCO', - 'FCS', - 'FCW', - 'FCZ', - 'FD', - 'FDA', - 'FDC', - 'FDDI', - 'FDBK', - 'FDR', - 'Fe', - 'FE', - 'FEAR', - 'FEAST', - 'FEATS', - 'FEB', - 'FEBA', - 'FED', - 'FEDEP', - 'FEF', - 'FEI', - 'FEMA', - 'FEMEXFUT', - 'FENAFUTH', - 'FENS', - 'FEP', - 'FEPAFUT', - 'FES', - 'FEST', - 'FET', - 'FEZ', - 'ff', - 'fF', - 'FF', - 'FFA', - 'FFB', - 'FFC', - 'FFE', - 'FFF', - 'FFG', - 'FFOV', - 'FFR', - 'FFS', - 'FFT', - 'FG', - 'fg', - 'FG', - 'FGR', - 'fH', - 'FHFIF', - 'FHI', - 'fi', - 'FI', - 'FIA', - 'FIAT', - 'FIB', - 'FIBA', - 'FIBUA', - 'FIC', - 'FID', - 'FIE', - 'FIFA', - 'FIFe', - 'FIFO', - 'FIFRA', - 'FIG', - 'FIGJAM', - 'fij', - 'FILA', - 'FILO', - 'FIMPT', - 'fin', - 'FIN', - 'FINA', - 'FIPS', - 'FIR', - 'FIRES', - 'FIS', - 'FISA', - 'FISH', - 'FiST', - 'FITS', - 'FIU', - 'fj', - 'fJ', - 'FJ', - 'FJD', - 'FJI', - 'FJN', - 'fK', - 'FK', - 'FKP', - 'FKSM', - 'FKT', - 'fl', - 'fL', - 'FL', - 'FLA', - 'FLAB', - 'FLAC', - 'FLAC', - 'FLAK', - 'FLB', - 'FLET', - 'FLG', - 'FLIP', - 'FLIR', - 'FLK', - 'FLOT', - 'FLSA', - 'FLQ', - 'FLQ', - 'FLTSAT', - 'FLTSATCOM', - 'FLUBOS', - 'FM', - 'fm', - 'Fm', - 'FM', - 'FMC', - 'FMCA', - 'FMCAfam', - 'FME', - 'FMECA', - 'FMJD', - 'FMLA', - 'Fmr', - 'fMRI', - 'FMS', - 'FMSF', - 'FMTV', - 'FMCG', - 'fN', - 'FNA', - 'fo', - 'FO', - 'FOAD', - 'FOAF', - 'FoB', - 'FOB', - 'FOC', - 'FoE', - 'FOGC', - 'FOH', - 'FOIA', - 'FOM', - 'FONSI', - 'FOO', - 'FOR', - 'Fortran', - 'FOS', - 'FoS', - 'FOSE', - 'FOSE', - 'FOT', - 'FOTA', - 'FOTC', - 'FOUO', - 'FOV', - 'FP', - 'FPF', - 'fps', - 'FPS', - 'FOOP', - 'FQ', - 'FQDN', - 'fr', - 'Fr', - 'FR', - 'fra', - 'FRA', - 'FRAGO', - 'FRAND', - 'FRCP', - 'FRICBM', - 'frr', - 'FRR', - 'FRO', - 'FRS', - 'FRSA', - 'fry', - 'FRY', - 'fs', - 'fS', - 'FS', - 'FSA', - 'FSB', - 'FSC', - 'FSCL', - 'FSE', - 'FSF', - 'FSH', - 'FSI', - 'FSK', - 'FSM', - 'FSO', - 'FSS', - 'FSSP', - 'FST', - 'FSTC', - 'FSV', - 'fT', - 'FTC', - 'FTE', - 'FTL', - 'FTP', - 'FTSE', - 'FTW', - 'FTS', - 'FTS', - 'FU', - 'FUBAR', - 'FUCK', - 'FUD', - 'ful', - 'fV', - 'fW', - 'FW', - 'FWC', - 'FWF', - 'FWI', - 'FWIW', - 'FWS', - 'FWT', - 'FX', - 'FXX', - 'fy', - 'FY', - 'FYA', - 'FYDP', - 'FYI', - 'FYROM', - 'FZDZ', - 'FZFG', - 'FZRA', - 'g', - 'G', - 'G1', - 'G2', - 'G3', - 'G4', - 'G5', - 'G6', - 'G7', - 'G8', - 'G9', - 'G10', - 'G15', - 'G20', - 'G24', - 'G77', - 'Ga', - 'GA', - 'GAAR', - 'GAB', - 'GABA', - 'GAD', - 'GAF', - 'GALCIT', - 'GAO', - 'GAW', - 'GB', - 'GBBC', - 'GBE', - 'GBH', - 'GBP', - 'GBR', - 'GBU', - 'GC', - 'GCA', - 'GCCS', - 'GCD', - 'GCF', - 'GCI', - 'GCN', - 'GCS', - 'GCSE', - 'gd', - 'Gd', - 'GD', - 'GDC', - 'GDL', - 'GDP', - 'GDR', - 'GDR', - 'GDW', - 'Ge', - 'GE', - 'GEBCO', - 'GEC', - 'GED', - 'GEEP', - 'GEL', - 'GEMA', - 'GEN', - 'GEO', - 'GEOS', - 'GEQ', - 'GER', - 'Gestapo', - 'GeV', - 'GEV', - 'GF', - 'GFCF', - 'GFCI', - 'GFDL', - 'GFE', - 'GFI', - 'GFL', - 'GFN', - 'GFY', - 'GG', - 'GGK', - 'GGP', - 'GH', - 'GHA', - 'GHB', - 'GHC', - 'Gi', - 'GI', - 'GIB', - 'GIF', - 'GIG', - 'GIGO', - 'GIMP', - 'GIN', - 'GINK', - 'GINO', - 'GIP', - 'GIS', - 'GISS', - 'GJ', - 'GJD', - 'GK', - 'gl', - 'GL', - 'gla', - 'GLAAD', - 'GLAC', - 'GLAST', - 'GLBA', - 'GLC', - 'gle', - 'glg', - 'GLINT', - 'GLONASS', - 'GLP', - 'GLUT', - 'glv', - 'GLV', - 'Gm', - 'GM', - 'GMAC', - 'GMB', - 'GmbH', - 'GMC', - 'GMD', - 'GMFSC', - 'GMI', - 'GML', - 'GMO', - 'GMPCS', - 'GMT', - 'gn', - 'GN', - 'GNAS', - 'GNB', - 'GNC', - 'GNF', - 'GNOME', - 'GNP', - 'GNQ', - 'GnRH', - 'GNU', - 'GO', - 'GOA', - 'GOAT', - 'GOC', - 'GOES', - 'GOP', - 'gorp', - 'GOSC', - 'GOSH', - 'GOSP', - 'GOTS', - 'GOYADIN', - 'GP', - 'GPA', - 'GPC', - 'GPG', - 'GPIB', - 'GPL', - 'GPMP', - 'GPO', - 'GPPC', - 'GPR', - 'GPS', - 'GPT', - 'GPU', - 'GQ', - 'GR', - 'GRAIL', - 'GRAS', - 'GRB', - 'GRC', - 'GRD', - 'GRE', - 'grep', - 'GRid', - 'GRID', - 'GriT', - 'GRL', - 'GRN', - 'grn', - 'GRP', - 'GRU', - 'Gs', - 'GS', - 'GSR', - 'GSA', - 'GSD', - 'GSG', - 'GSGB', - 'GSK', - 'GSM', - 'GSN', - 'GSO', - 'GSOH', - 'GSP', - 'GSS', - 'GST', - 'GSW', - 'GT', - 'GTA', - 'GTB', - 'GTC', - 'GTD', - 'GTG', - 'GTK', - 'GTGN', - 'GTM', - 'GTQ', - 'GTV', - 'gu', - 'GU', - 'GUA', - 'GUF', - 'GUI', - 'GUILT', - 'guj', - 'GULCR', - 'Gulag', - 'GUM', - 'GUT', - 'gv', - 'GV', - 'GW', - 'GWARS', - 'GWAS', - 'GWOT', - 'GWS', - 'GXA', - 'GXE', - 'GXF', - 'GXH', - 'GXL', - 'GXM', - 'GXP', - 'GxP', - 'GXY', - 'GY', - 'GYD', - 'GYY', - 'GZ', - 'GZK', - 'h', - 'H', - 'ha', - 'HA', - 'HAARP', - 'HAART', - 'HAI', - 'HAL', - 'HALO', - 'HALT', - 'HAM', - 'HANS', - 'HARM', - 'hat', - 'HAT', - 'hau', - 'HAW', - 'HAWK', - 'HAZ', - 'HAZMAT', - 'HBCD', - 'HBCI', - 'HBO', - 'HBR', - 'HBT', - 'HBV', - 'HCCI', - 'HCF', - 'HCI', - 'HCSC', - 'HCT', - 'HD', - 'HDD', - 'HDL', - 'HDM', - 'HDMI', - 'HDR', - 'HDRA', - 'HDTV', - 'he', - 'He', - 'HE', - 'HEAT', - 'heb', - 'HEBA', - 'HEER', - 'HEIC', - 'HEMP', - 'HEO', - 'HEOSS', - 'HEP', - 'HEPA', - 'her', - 'HESH', - 'HESV', - 'HETT', - 'HEU', - 'Hewitts', - 'Hf', - 'HF', - 'HFAC', - 'HFE', - 'HFE', - 'HFEA', - 'Hg', - 'HGC', - 'HGF', - 'HGTV', - 'HHD', - 'HHB', - 'hi', - 'HI', - 'HIDACZ', - 'HIH', - 'HIM', - 'HIMAD', - 'HIMEZ', - 'HIMYM', - 'hin', - 'HIPAA', - 'Hipparcos', - 'Hird', - 'HiRISE', - 'HIT', - 'HIV', - 'HIWT', - 'HJ', - 'HJD', - 'HK', - 'HKD', - 'HKG', - 'HKS', - 'hL', - 'HL', - 'HLA', - 'HLL', - 'HLN', - 'HLOS', - 'HLS', - 'HLVW', - 'HM', - 'HMCE', - 'HMCS', - 'HMD', - 'HMF', - 'HMM', - 'HMMWV', - 'hmo', - 'HMO', - 'HMP', - 'HMRC', - 'HMS', - 'HMSO', - 'HMV', - 'HN', - 'HND', - 'HNL', - 'ho', - 'Ho', - 'HO', - 'HOLLAND', - 'HOMES', - 'HONY', - 'HOPI', - 'HoReCa', - 'HORIZON', - 'HOSTAC', - 'HOT', - 'HOTAS', - 'HOV', - 'hp', - 'HP', - 'HPA', - 'HPCL', - 'HPLC', - 'HPT', - 'HPV', - 'HQ', - 'HQDA', - 'hr', - 'HR', - 'HRBL', - 'HRD', - 'HRED', - 'HRF', - 'HRH', - 'HRK', - 'HRM', - 'HRM', - 'HRT', - 'hrv', - 'HRV', - 'Hs', - 'HSBC', - 'HSC', - 'HSM', - 'HSMAD', - 'HSN', - 'HSPH', - 'HSS', - 'HSSI', - 'HST', - 'HSV', - 'ht', - 'HT', - 'HTA', - 'HTC', - 'HTFU', - 'HTG', - 'HTH', - 'HTI', - 'HTML', - 'HTR', - 'HTS', - 'HTTP', - 'HU', - 'HUD', - 'HUF', - 'HUHA', - 'HUMINT', - 'hun', - 'HUN', - 'HURD', - 'HVU', - 'HV', - 'HVAC', - 'HVAP', - 'HVHF', - 'HVM', - 'HVO', - 'HVP', - 'HW', - 'HWM', - 'HX', - 'HXB', - 'hy', - 'hye', - 'HYS', - 'hz', - 'Hz', - 'HZ', - 'I', - 'ia', - 'IA', - 'IAAF', - 'IAAL', - 'IAB', - 'IACREOT', - 'IAD', - 'IADS', - 'IAEA', - 'IAF', - 'IAFG', - 'IAI', - 'IANA', - 'IANAL', - 'IAQ', - 'IAS', - 'IASIP', - 'IATA', - 'IATF', - 'IATSE', - 'IAU', - 'IAUC', - 'IAW', - 'IB', - 'IBA', - 'IBD', - 'IBDM', - 'IBDS', - 'IBM', - 'ibo', - 'IBRD', - 'IBS', - 'IC', - 'ICANN', - 'ICAO', - 'ICBM', - 'ICBM', - 'ICBN', - 'ICC', - 'ICD', - 'ICE', - 'ICES', - 'ICF', - 'ICHTHYS', - 'ICM', - 'ICMP', - 'ICNAF', - 'ICNI', - 'ICO', - 'ICP', - 'ICPE', - 'ICRAF', - 'ICRISAT', - 'ICRL', - 'ICRS', - 'ICS', - 'ICSI', - 'ICT', - 'ICTH', - 'ICV', - 'ICW', - 'ICYMI', - 'ICZN', - 'id', - 'ID', - 'IDA', - 'IDC', - 'IDDI', - 'IDE', - 'IDeA', - 'IDEA', - 'IDF', - 'IDGAF', - 'IDK', - 'IDKIBTI', - 'IDL', - 'IDN', - 'ido', - 'IDR', - 'ie', - 'IE', - 'IEA', - 'IEC', - 'IED', - 'IEEE', - 'IELTS', - 'IER', - 'IET', - 'IET', - 'IETF', - 'IEW', - 'IFAB', - 'IFAH', - 'IFAP', - 'IFC', - 'IFF', - 'IFFN', - 'IFO', - 'IFOR', - 'IFOV', - 'IFPI', - 'IFR', - 'IFRB', - 'IFV', - 'ig', - 'IG', - 'IGNOU', - 'IGS', - 'IGY', - 'IHH', - 'IHO', - 'IHOP', - 'IHR', - 'IHSI', - 'ii', - 'II', - 'IICA', - 'IIE', - 'iii', - 'III', - 'IIRC', - 'IISS', - 'IIT', - 'IJN', - 'IJWP', - 'ik', - 'IKEA', - 'iku', - 'IL', - 'ILC', - 'ILDS', - 'ile', - 'ILO', - 'ILRI', - 'ILS', - 'ILY', - 'IM', - 'IMA', - 'IMBD', - 'IMDb', - 'IME', - 'IMF', - 'IMHO', - 'IML', - 'IMLAST', - 'IMNM', - 'IMNSHO', - 'IMPAC', - 'IMPACT', - 'IMPATT', - 'IMPT', - 'IMR', - 'IMRI', - 'IMRO', - 'IMRL', - 'IMS', - 'IMSA', - 'IMsL', - 'IMT', - 'IMU', - 'In', - 'ina', - 'INA', - 'INAS', - 'INC', - 'INCITS', - 'ind', - 'IND', - 'inet', - 'INDIGO', - 'INFORMS', - 'INFOSEC', - 'INLA', - 'INMARSAT', - 'INR', - 'INRI', - 'INRIA', - 'INS', - 'InSAR', - 'INST', - 'INTSUM', - 'INTERFET', - 'io', - 'IO', - 'IOC', - 'IOLTA', - 'IOM', - 'IONA', - 'IOT', - 'IOU', - 'IP', - 'IPA', - 'IPB', - 'IPCC', - 'ipk', - 'IPL', - 'IPN', - 'IPO', - 'IPR', - 'IPT', - 'IPTS', - 'IPTV', - 'IPW', - 'iq', - 'IQ', - 'IQD', - 'Ir', - 'IR', - 'IRA', - 'IRAD', - 'IRAN', - 'IRAS', - 'IRB', - 'IRBM', - 'IRC', - 'IREA', - 'IRL', - 'IRM', - 'IRN', - 'IRINN', - 'IRO', - 'IRQ', - 'IRR', - 'IRS', - 'IRSN', - 'IRST', - 'is', - 'ISA', - 'ISAAA', - 'ISAF', - 'ISAR', - 'ISB', - 'ISBN', - 'ISCCP', - 'ISCII', - 'ISDN', - 'ISEF', - 'ISEN', - 'ISF', - 'ISI', - 'ISIL', - 'ISIS', - 'ISK', - 'isl', - 'ISL', - 'ISLN', - 'ISM', - 'ISMB', - 'ISMC', - 'ISO', - 'ISOGP', - 'ISP', - 'ISPE', - 'ISR', - 'ISRO', - 'ISS', - 'ISSCR', - 'ISSN', - 'IST', - 'ISTAR', - 'ISTC', - 'ISTS', - 'ISU', - 'it', - 'IT', - 'ita', - 'ITA', - 'ITAG', - 'ITALY', - 'ITAS', - 'ITC', - 'ITCZ', - 'ITEC', - 'ITEMS', - 'ITER', - 'ITF', - 'ITK', - 'ITN', - 'ITS', - 'ITT', - 'ITTO', - 'ITU', - 'iu', - 'IUCN', - 'IUPAC', - 'IUSS', - 'IV', - 'IVD', - 'Iveco', - 'IVF', - 'IVI', - 'IVIS', - 'IVL', - 'IVM', - 'IVO', - 'IW', - 'IWARS', - 'IWC', - 'IWW', - 'IX', - 'IXT', - 'IY', - 'IZ', - 'J', - 'J1', - 'J2', - 'J2EE', - 'J2ME', - 'J2SE', - 'J3', - 'J4', - 'J5', - 'J6', - 'J7', - 'J8', - 'J9', - 'ja', - 'JA', - 'JAFT', - 'JAG', - 'JAL', - 'JALLC', - 'JAM', - 'JAMA', - 'JANET', - 'JANUS', - 'JATO', - 'jav', - 'JB', - 'JB', - 'JBFSA', - 'JCATS', - 'JCDB', - 'JCDS', - 'JCIDS', - 'JCIET', - 'JCL', - 'JCMOTF', - 'JCP', - 'JCPM', - 'JCS', - 'JCW', - 'JD', - 'JDAM', - 'JDBC', - 'JDCC', - 'JDEM', - 'JDK', - 'JDL', - 'JDM', - 'JE', - 'JEB', - 'JEDP', - 'JEE', - 'JEFX', - 'JEPD', - 'JET', - 'JEZ', - 'JFACC', - 'JFACTSU', - 'JFC', - 'JFCOM', - 'JFET', - 'JFK', - 'JFLCC', - 'JFMCC', - 'JFO', - 'JFSOCC', - 'JFTX', - 'JH', - 'JH', - 'JI', - 'JIB', - 'JIC', - 'JINI', - 'JITC', - 'JIVE', - 'JJAP', - 'JK', - 'JL', - 'JLA', - 'JM', - 'JMD', - 'JMS', - 'JN', - 'JNI', - 'JNLP', - 'JO', - 'JOA', - 'JOD', - 'Jofa', - 'JOPES', - 'JOR', - 'JOTS', - 'JSP', - 'JP', - 'JPEG', - 'JPL', - 'jpn', - 'JPN', - 'JPOW', - 'JPY', - 'JQ', - 'JRA', - 'JRDF', - 'JRE', - 'JREF', - 'JROC', - 'JROCM', - 'JRRF', - 'JRSAI', - 'JSAF', - 'JSAP', - 'JSCSC', - 'JSD', - 'JSEAD', - 'JSF', - 'JSIMS', - 'JSOA', - 'JSOTF', - 'JSP', - 'JSR', - 'JSS', - 'JSTARS', - 'JT', - 'JTAG', - 'JTAGS', - 'JTC', - 'JTF', - 'JTFEX', - 'JTIDS', - 'JTLS', - 'JTMD', - 'JTN', - 'JTRS', - 'JU', - 'JUGFET', - 'JUICE', - 'JUO', - 'jv', - 'JV', - 'JVM', - 'JW', - 'JWARS', - 'JWP', - 'JWST', - 'JY', - 'JZ', - 'k', - 'K', - 'K9', - 'ka', - 'kA', - 'Ka', - 'KA', - 'KAF', - 'kal', - 'kan', - 'KANUKOKA', - 'KARNAC', - 'KARR', - 'kas', - 'kat', - 'KATUSA', - 'kau', - 'kaz', - 'KAZ', - 'KBE', - 'KBO', - 'KBVB', - 'kC', - 'KCK', - 'KCL', - 'KCMO', - 'KCS', - 'KD', - 'KE', - 'KEDO', - 'KEN', - 'KERA', - 'kerma', - 'KES', - 'KESR', - 'KET', - 'kF', - 'KFC', - 'KFOR', - 'KFUPM', - 'kg', - 'KG', - 'KGB', - 'KGO', - 'KGS', - 'KGZ', - 'kH', - 'KH', - 'khm', - 'KHL', - 'KHM', - 'KHR', - 'KHz', - 'ki', - 'Ki', - 'KI', - 'KIA', - 'KIDS', - 'kik', - 'kin', - 'kir', - 'KIR', - 'KISS', - 'KITECH', - 'KITT', - 'KIT', - 'kj', - 'kJ', - 'kk', - 'kK', - 'KK', - 'KKK', - 'kl', - 'kL', - 'KL', - 'KLENOT', - 'KLM', - 'KLOC', - 'KLS', - 'km', - 'KM', - 'KMF', - 'KMFDM', - 'KMR', - 'kn', - 'kN', - 'KN', - 'KNA', - 'KNVB', - 'ko', - 'KO', - 'kom', - 'kon', - 'kor', - 'KOR', - 'KP', - 'KPMG', - 'KPNO', - 'KPV', - 'KPVT', - 'KPW', - 'KQ', - 'kr', - 'Kr', - 'KR', - 'KRW', - 'ks', - 'kS', - 'KS', - 'KSC', - 'KSI', - 'KSM', - 'KSU', - 'kT', - 'KT', - 'KTS', - 'ku', - 'KU', - 'kua', - 'KUL', - 'kur', - 'KUW', - 'kv', - 'kV', - 'KV', - 'KV', - 'KVM', - 'KVT', - 'kw', - 'kW', - 'KW', - 'KWB', - 'KWD', - 'KWT', - 'KX', - 'ky', - 'KY', - 'KYD', - 'KZ', - 'KZN', - 'KZT', - 'L', - 'la', - 'La', - 'LA', - 'LAAS', - 'LAB', - 'LabVIEW', - 'LACMA', - 'LAD', - 'LADW', - 'LAESI', - 'LAK', - 'LAMP', - 'LAN', - 'LANL', - 'lao', - 'LAO', - 'LAPD', - 'LAR', - 'LARC', - 'LARP', - 'LAS', - 'Laser', - 'lat', - 'LAT', - 'LATA', - 'LATCH', - 'lav', - 'LAV', - 'LAW', - 'LAX', - 'lb', - 'LB', - 'LBH', - 'LBJ', - 'LBN', - 'LBNL', - 'LBP', - 'LBR', - 'LBS', - 'lbs', - 'LBT', - 'LBW', - 'LBY', - 'LC', - 'LCA', - 'LCC', - 'LCCP', - 'LCD', - 'LCE', - 'LCM', - 'LCol', - 'LCS', - 'LD', - 'LDA', - 'LDAP', - 'LDC', - 'LDL', - 'LDMOS', - 'LDS', - 'LDV', - 'LDW', - 'LE', - 'LEC', - 'LED', - 'Legs', - 'LEGAD', - 'LEMOSS', - 'LEN', - 'LEO', - 'LEP', - 'LER', - 'LEU', - 'LF', - 'LFO', - 'LFTR', - 'lg', - 'LG', - 'LGB', - 'LGBT', - 'LGLO', - 'LGM', - 'LGS', - 'LH', - 'LHC', - 'LHD', - 'LHS', - 'li', - 'Li', - 'LI', - 'LIBOR', - 'LIBS', - 'LIE', - 'LIFO', - 'LIGO', - 'lim', - 'lin', - 'LINEAR', - 'LISA', - 'lit', - 'LITAS', - 'LIVE', - 'LJ', - 'LJJ', - 'LK', - 'LKA', - 'LKL', - 'LKR', - 'LKR4', - 'LL', - 'LLAD', - 'LLB', - 'LLC', - 'LLGC', - 'LLLTV', - 'LLNL', - 'LLP', - 'LLTI', - 'LLTR', - 'LMAO', - 'LMFAO', - 'LMB', - 'LMN', - 'LMS', - 'LMS', - 'LMV', - 'ln', - 'LNB', - 'LNER', - 'LNG', - 'LNO', - 'LNR', - 'lo', - 'LO', - 'LOA', - 'LOC', - 'LOD', - 'LoD', - 'LOFAR', - 'LOGPAC', - 'LOL', - 'LOMEZ', - 'LONEOS', - 'LOOGY', - 'loq', - 'LORTID', - 'LOS', - 'LOSAT', - 'LOTA', - 'LOTIS', - 'LOTS', - 'LP', - 'LPC', - 'LPD', - 'LPG', - 'LPI', - 'LPS', - 'LQ', - 'Lr', - 'LR', - 'LRAD', - 'LRC', - 'LRD', - 'LRF', - 'LRGW', - 'LRICBM', - 'LRIP', - 'LRP', - 'LRPE', - 'LRS', - 'LRU', - 'LS', - 'LSAT', - 'LSD', - 'LSE', - 'LSI', - 'LSIF', - 'LSL', - 'LSND', - 'LSO', - 'LSST', - 'LST', - 'LSVW', - 'lt', - 'Lt', - 'LtC', - 'LT', - 'LTC', - 'Ltd', - 'LTDP', - 'LTG', - 'LTI', - 'LTM', - 'LTL', - 'LTP', - 'LTU', - 'ltz', - 'lu', - 'Lu', - 'LU', - 'LU', - 'lub', - 'LUCA', - 'lug', - 'LUG', - 'LULU', - 'LUT', - 'LUVW', - 'LUX', - 'lv', - 'LV', - 'LVA', - 'LVAD', - 'LVEF', - 'LVL', - 'Lw', - 'LW', - 'LWR', - 'LWRC', - 'LWS', - 'LX', - 'LX', - 'LY', - 'LYAO', - 'LYD', - 'LYLAS', - 'LZ', - 'm', - 'M', - 'mA', - 'Ma', - 'MA', - 'MAA', - 'MAAC', - 'MAC', - 'MACA', - 'MACE', - 'MACHO', - 'MAD', - 'MADD', - 'MAF', - 'MAFF', - 'MAGIC', - 'MAGNETAR', - 'MAGTF', - 'mah', - 'Mahasz', - 'mal', - 'MALD', - 'MANPADS', - 'MANPRINT', - 'MAO', - 'MAOI', - 'MAPS', - 'MAQS', - 'mar', - 'MAR', - 'MarBEF', - 'MARC', - 'MARCENT', - 'MARFORCC', - 'MARRS', - 'MAS', - 'MASAS', - 'Maser', - 'MASH', - 'MASINT', - 'MATREX', - 'MATS', - 'MAVS', - 'MAW', - 'MAX', - 'MB', - 'MBA', - 'MBI', - 'MBBL', - 'MBCC', - 'MBCS', - 'MBE', - 'MBIDST', - 'MBM', - 'mbsf', - 'MBT', - 'MBTA', - 'MBTI', - 'mC', - 'MC', - 'MCA', - 'MCC', - 'MCD', - 'MCDL', - 'MCFT', - 'MCG', - 'MCI', - 'MCIS', - 'MCM', - 'MCMV', - 'MCNC', - 'MCO', - 'MCP', - 'MCR', - 'MCSF', - 'MCT', - 'MCTIS', - 'MCU', - 'Md', - 'MD', - 'MDA', - 'MDF', - 'MDG', - 'MDL', - 'MDM', - 'MDMA', - 'MDNA', - 'MDO', - 'MDP', - 'MDT', - 'MDV', - 'MDW', - 'Me', - 'ME', - 'MEA', - 'MEAC', - 'MEC', - 'MECC', - 'MECCA', - 'MECO', - 'MEDEVAC', - 'MEDLI', - 'MEDS', - 'MEF', - 'MEG', - 'MEI', - 'MEK', - 'MEL', - 'MELT', - 'MEM', - 'MEMS', - 'MEO', - 'MEP', - 'MER', - 'MERL', - 'MES', - 'MESSENGER', - 'MET', - 'METAR', - 'METL', - 'METOC', - 'MEU', - 'MeV', - 'MEV', - 'MEX', - 'MEZ', - 'MES', - 'mF', - 'MF', - 'MFSB', - 'mg', - 'Mg', - 'MG', - 'MGA', - 'MGB', - 'MGM', - 'MGPAM', - 'MGS', - 'mh', - 'mH', - 'MH', - 'MHC', - 'MHHW', - 'MHL', - 'MHLW', - 'MHRA', - 'MHW', - 'MHWN', - 'MHWS', - 'MHz', - 'mi', - 'Mi', - 'MI', - 'MIA', - 'MIAA', - 'MIB', - 'MIC', - 'MICLIC', - 'MICOM', - 'MICV', - 'MID', - 'MIDI', - 'MIDAS', - 'MIDS', - 'MIFC', - 'MIFG', - 'MIFPC', - 'MIKE', - 'MIL', - 'MILAN', - 'MILC', - 'MILES', - 'MILF', - 'MILSATCOM', - 'MILSET', - 'MILSTRIP', - 'MIMAROPA', - 'MIMOSA', - 'MIMPT', - 'MIP', - 'MIPC', - 'MIRA', - 'MIRV', - 'MIS', - 'MISREP', - 'MISSI', - 'MIT', - 'MITL', - 'MITS', - 'mJ', - 'MJ', - 'MJD', - 'MJP', - 'mk', - 'mK', - 'MK', - 'mkd', - 'MKD', - 'MKG', - 'MKO', - 'MKS', - 'MKSA', - 'MKT', - 'ml', - 'mL', - 'ML', - 'MLA', - 'MLB', - 'MLC', - 'MLE', - 'mlg', - 'MLHW', - 'MLI', - 'MLK', - 'MLL', - 'MLLW', - 'MLP', - 'MLRS', - 'MLS', - 'MLSE', - 'MLSR', - 'mlt', - 'MLT', - 'MLVW', - 'MLW', - 'MLWN', - 'MLWS', - 'mm', - 'Mm', - 'MM', - 'MMA', - 'MMC', - 'MMCM', - 'MMCS', - 'MMEV', - 'MMI', - 'MMI', - 'MMIC', - 'MMIC', - 'MMI', - 'MMK', - 'MMO', - 'MMOR', - 'MMORPG', - 'MMPR', - 'MMR', - 'MMS', - 'MMU', - 'MMW', - 'mn', - 'mN', - 'Mn', - 'MN', - 'MNB', - 'MND', - 'MNE', - 'MNG', - 'MNMF', - 'MNP', - 'MNS', - 'MNT', - 'mo', - 'Mo', - 'MO', - 'MOA', - 'MOAB', - 'MOB', - 'MOC', - 'MOCAS', - 'MoD', - 'MOD', - 'MoDAF', - 'modem', - 'ModSAF', - 'Moe', - 'MOE', - 'MOF', - 'MOFA', - 'MOH', - 'MOID', - 'MOJWA', - 'mol', - 'MOLO', - 'MoM', - 'MOM', - 'MOM', - 'MoMA', - 'mon', - 'MOND', - 'MOO', - 'MOOTW', - 'MOP', - 'MOPE', - 'MOPP', - 'MORP', - 'MORS', - 'MOS', - 'MOSFET', - 'MOSS', - 'MOT', - 'MOTA', - 'MOTESS', - 'MOTO', - 'MOU', - 'MOUT', - 'MOVES', - 'MOW', - 'MOZ', - 'MP', - 'mPa', - 'MPa', - 'MPA', - 'MPAA', - 'MPC', - 'MPC', - 'MPEC', - 'MPEG', - 'MPEV', - 'MPF', - 'MPFC', - 'MPG', - 'MPMC', - 'MPSI', - 'MPSoC', - 'MPSRON', - 'MQ', - 'mr', - 'MR', - 'MRAV', - 'MRBM', - 'MRC', - 'MRD', - 'MRE', - 'MRGO', - 'MRGW', - 'mri', - 'MRI', - 'MRL', - 'MRO', - 'MRPharmS', - 'MRR', - 'MRS', - 'MRSA', - 'MRT', - 'MRTFB', - 'ms', - 'mS', - 'Ms', - 'MS', - 'msa', - 'MSB', - 'MSD', - 'MSDB', - 'MSDE', - 'MSDL', - 'MSE', - 'MSEC', - 'MSG', - 'MSH', - 'MSI', - 'MSIE', - 'MSL', - 'MSLP', - 'MSM', - 'MSN', - 'MSNBC', - 'MSO', - 'MSPA', - 'MSPB', - 'MSR', - 'MSSA', - 'MST', - 'MSU', - 'mt', - 'mT', - 'Mt', - 'MT', - 'MTA', - 'MTBF', - 'MtF', - 'MTF', - 'MTG', - 'MTL', - 'MTOE', - 'MTP', - 'MTQ', - 'MTR', - 'MTS', - 'MTT', - 'MTU', - 'MTV', - 'MTVL', - 'MTWS', - 'MU', - 'MUA', - 'MUR', - 'MUS', - 'mV', - 'MV', - 'MVC', - 'MVFC', - 'MVNO', - 'MVR', - 'mW', - 'MW', - 'MWC', - 'MWESH', - 'MWI', - 'MWK', - 'MWOA', - 'MWR', - 'MWTB', - 'MX', - 'MXN', - 'my', - 'MY', - 'mya', - 'MYOB', - 'MYR', - 'MYS', - 'MYT', - 'MZ', - 'MZN', - 'n', - 'N', - 'N3F', - 'na', - 'nA', - 'Na', - 'NA', - 'NAA', - 'NAAD', - 'NAAFI', - 'NAAK', - 'NAACP', - 'NAAQS', - 'NAAWS', - 'NABCI', - 'NAC', - 'NACA', - 'NACC', - 'NACISA', - 'NACMA', - 'NACRA', - 'NAD', - 'NAEC', - 'NAF', - 'NAFLD', - 'NAFO', - 'NAFERA', - 'NAFTA', - 'NAGPRA', - 'NAI', - 'NAICS', - 'NAIL', - 'NAILS', - 'NAIRU', - 'NAK', - 'NAM', - 'NAMBLA', - 'NAMRL', - 'NaN', - 'NAO', - 'NAPDD', - 'NAPS', - 'NARA', - 'NARAL', - 'NARDAC', - 'NARDIC', - 'NARPV', - 'NAS', - 'NASA', - 'NASCAR', - 'NASDAQ', - 'NASED', - 'NASH', - 'NASP', - 'NASS', - 'NatGeo', - 'NATO', - 'nau', - 'NaUKMA', - 'nav', - 'NAV', - 'NAVCAMS', - 'NAVCC', - 'NAVCENT', - 'NAVDAC', - 'NAVEUR', - 'NAVFE', - 'NAVFOR', - 'NAVMACS', - 'NAVNET', - 'NAVS', - 'NAVSAT', - 'NAVSPACECOM', - 'NAVSPASUR', - 'NAVSTAR', - 'NAVTACNET', - 'NAWC', - 'NAWMP', - 'nb', - 'Nb', - 'NB', - 'NBA', - 'NBACC', - 'NBC', - 'NBCRS', - 'NBIC', - 'nbl', - 'NBL', - 'NBT', - 'nC', - 'NC', - 'NCA', - 'NCAA', - 'NCAR', - 'NCB', - 'NCCAM', - 'NCCUSL', - 'NCDDR', - 'NCES', - 'NCI', - 'NCIS', - 'NCL', - 'NCLR', - 'NCMO', - 'NCND', - 'NCO', - 'NCP', - 'NCR', - 'NCTR', - 'NCW', - 'nd', - 'Nd', - 'ND', - 'NDAA', - 'NDBP', - 'NDCC', - 'nde', - 'NDH', - 'ndo', - 'NDP', - 'NDS', - 'NDU', - 'ne', - 'Ne', - 'NE', - 'NEA', - 'NEAT', - 'NEC', - 'NED', - 'NECTEC', - 'NEI', - 'NEMS', - 'NEO', - 'nep', - 'NEPA', - 'NER', - 'NERICA', - 'NESDIS', - 'NESP', - 'NET', - 'NETCU', - 'NETL', - 'NETWARS', - 'NEWS', - 'NEX', - 'nF', - 'NF', - 'NFA', - 'NFC', - 'NFER', - 'NFG', - 'NFHS', - 'NFI', - 'NFK', - 'NFL', - 'Nfld', - 'NFPA', - 'NFT', - 'ng', - 'NG', - 'NGA', - 'NGH', - 'NGATS', - 'NGIC', - 'NGN', - 'NGO', - 'NGR', - 'NGS', - 'nH', - 'NH', - 'NHANES', - 'NHB', - 'NHB', - 'NHL', - 'NHO', - 'NHRA', - 'NHS', - 'Ni', - 'NI', - 'NIAAA', - 'NIAID', - 'NIAG', - 'NIAMD', - 'NIB', - 'NIBP', - 'NIC', - 'NicA', - 'NICAP', - 'NICE', - 'NICHD', - 'NICP', - 'NIDDK', - 'NIDRR', - 'NIEHS', - 'NIG', - 'NIH', - 'NIIN', - 'NIMA', - 'NIMBY', - 'NIMG', - 'NiMH', - 'NIMH', - 'NIN', - 'NIO', - 'NIPR', - 'NIR', - 'NISAC', - 'NISCAP', - 'NISER', - 'NIST', - 'NIT', - 'NITFS', - 'NIU', - 'nJ', - 'NJ', - 'NJHS', - 'NJPW', - 'nK', - 'NKA', - 'NKVD', - 'nl', - 'nL', - 'NL', - 'NLB', - 'nld', - 'NLD', - 'NLF', - 'NLL', - 'NLP', - 'NLW', - 'nm', - 'NM', - 'NMCI', - 'NMCS', - 'NMD', - 'NMN', - 'NMOS', - 'NMSG', - 'NMR', - 'NMHNFG', - 'nn', - 'nN', - 'NNE', - 'nno', - 'NNSA', - 'NNW', - 'no', - 'No', - 'NO', - 'NOAA', - 'NOAF', - 'NOAO', - 'nob', - 'NOC', - 'NODEF', - 'NOK', - 'NOLA', - 'NOGI', - 'NOLF', - 'NOM', - 'NOME', - 'NoMBO', - 'NOME', - 'NOPA', - 'nor', - 'NOR', - 'NORAD', - 'NORCECA', - 'NORELPREF', - 'NORML', - 'NORTHAG', - 'NOS', - 'NOSA', - 'NOTAFLOF', - 'NOTAM', - 'NOW', - 'Np', - 'NP', - 'NP', - 'NPB', - 'NPC', - 'NPCS', - 'NPD', - 'NPDI', - 'NPG', - 'NPH', - 'NPL', - 'NPOV', - 'NPR', - 'NQ', - 'NQOCD', - 'NPO', - 'nr', - 'NR', - 'NRA', - 'NRAO', - 'NRC', - 'NRDC', - 'NREL', - 'NRK', - 'NRMP', - 'NRO', - 'NRT', - 'NRTA', - 'NRU', - 'ns', - 'nS', - 'NS', - 'NSA', - 'NSABB', - 'NSAID', - 'NSB', - 'NSC', - 'NSDAP', - 'NSDL', - 'NSG', - 'NSN', - 'NSNA', - 'NSO', - 'NSS', - 'NST', - 'NSTAC', - 'NSV', - 'NSW', - 'nT', - 'NT', - 'NTC', - 'NTDS', - 'NTP', - 'NTR', - 'NTRA', - 'NTSB', - 'NTSC', - 'NTV', - 'NTY', - 'NTZ', - 'NU', - 'NUI', - 'NUL', - 'NUTS', - 'NUWC', - 'nv', - 'nV', - 'NV', - 'NVEOL', - 'NVESD', - 'NVG', - 'NVL', - 'nW', - 'NW', - 'NW', - 'NWA', - 'NWF', - 'NWFZ', - 'NWHN', - 'nWo', - 'NWOAHM', - 'NWOBHM', - 'NWRA', - 'NWSL', - 'NWT', - 'NX', - 'NXA', - 'NXD', - 'NXE', - 'NXG', - 'NXL', - 'NXN', - 'NXP', - 'ny', - 'NY', - 'nya', - 'NYHA', - 'NYO', - 'NYRA', - 'NYSE', - 'NZ', - 'NZC', - 'NZD', - 'NZL', - 'NzL', - 'NZOSS', - 'NZRU', - 'O', - 'OA', - 'OAC', - 'OAP', - 'OAS', - 'OARSI', - 'OASIS', - 'OASIS', - 'OASIS', - 'OASP', - 'OAU', - 'ob', - 'OB', - 'OBE', - 'OBJ', - 'OBS', - 'OBSS', - 'OBX', - 'oc', - 'OC', - 'OCA', - 'OCD', - 'OCFS', - 'OCFS2', - 'oci', - 'OCIDS', - 'OCMCC', - 'OCN', - 'OCO', - 'OCOKA', - 'OCR', - 'OCS', - 'OD', - 'ODAS', - 'ODB', - 'ODBC', - 'ODI', - 'OEA', - 'OED', - 'OECD', - 'OEG', - 'OEIS', - 'OEM', - 'OFA', - 'OFDA', - 'OGC', - 'OGD', - 'OGO', - 'OGX', - 'OH', - 'OHC', - 'OHHLA', - 'OHIP', - 'OHMS', - 'OHP', - 'OHSS', - 'OIC', - 'OIE', - 'OIF', - 'OIOC', - 'oj', - 'OJ', - 'oji', - 'OJT', - 'OK', - 'OKC', - 'OLED', - 'OLS', - 'om', - 'OM', - 'OMA', - 'OMB', - 'OMD', - 'OME', - 'OMFG', - 'OMG', - 'OMGW', - 'OMN', - 'OMNCS', - 'OMP', - 'OMR', - 'OMS', - 'OMT', - 'OMW', - 'ON', - 'Onna', - 'ONC', - 'ONCE', - 'ONG', - 'ONH', - 'ONI', - 'ONS', - 'OO', - 'OOO', - 'OOAD', - 'OOBE', - 'OOCL', - 'OODA', - 'OOMF', - 'OOP', - 'OOS', - 'OOTW', - 'OP', - 'OPA', - 'OPAC', - 'OPCON', - 'OPCW', - 'OPEC', - 'OPFOR', - 'OPI', - 'OPINTEL', - 'OPLAN', - 'OPORD', - 'OPP', - 'OPS', - 'OPSEC', - 'OPSI', - 'OPTAR', - 'OPV', - 'OQ', - 'or', - 'OR', - 'ORAU', - 'ORB', - 'ORBAT', - 'ORD', - 'ORD', - 'ORF', - 'ori', - 'ORISE', - 'orm', - 'ORNL', - 'ORR', - 'ORSA', - 'OS', - 'os', - 'Os', - 'OSA', - 'OSCE', - 'OSD', - 'OSDL', - 'OSE', - 'OSERS', - 'OSETI', - 'OSHA', - 'OSI', - 'OSIA', - 'OSL', - 'OSIRIS', - 'OSM', - 'OSOTPBOIAS', - 'OSPA', - 'OSPA', - 'OSRH', - 'oss', - 'OSS', - 'OSU', - 'OTA', - 'OTAC', - 'OTB', - 'OTC', - 'OTD', - 'OTEA', - 'OTI', - 'OTO', - 'OTOH', - 'OTS', - 'OTSBH', - 'OTW', - 'OTYBL', - 'OTYRL', - 'OU', - 'OVC', - 'OVV', - 'OW', - 'OWN', - 'OX', - 'OXC', - 'OXF', - 'OXM', - 'OXR', - 'OXT', - 'Oy', - 'Oyj', - 'OZ', - 'p', - 'P', - 'P2P', - 'pa', - 'pA', - 'Pa', - 'PA', - 'PAA', - 'PAB', - 'PABX', - 'PAC', - 'PACOM', - 'PADD', - 'PADI', - 'PAEK', - 'PAFC', - 'PAH', - 'PAHO', - 'PAI', - 'PAK', - 'Pakistan', - 'PAL', - 'PAM', - 'Pamida', - 'PAMELA', - 'pan', - 'PAN', - 'PANS', - 'PAO', - 'PAOK', - 'PAPI', - 'PAR', - 'PARC', - 'PAROS', - 'PARP', - 'PARWIG', - 'PAS', - 'PASA', - 'PASGT', - 'PASR', - 'PATH', - 'PAWS', - 'PAYD', - 'PAYE', - 'Pb', - 'PB', - 'PBA', - 'PBB', - 'PBCK', - 'PBD', - 'PBDE', - 'PBI', - 'PBN', - 'PBOH', - 'PBR', - 'PBS', - 'PBRT', - 'PBT', - 'PBX', - 'pC', - 'PC', - 'PCAS', - 'PCB', - 'PCC', - 'PCCB', - 'PCCIP', - 'PCE', - 'PCI', - 'PCIe', - 'PCL', - 'PCMCIA', - 'PCN', - 'PCP', - 'PCPI', - 'PCR', - 'PCSSD', - 'PCT', - 'PCTE', - 'PCZ', - 'Pd', - 'PD', - 'PD', - 'PDA', - 'PDD', - 'PDF', - 'PDG', - 'PDL', - 'PDN', - 'PDP', - 'PDQ', - 'PDRY', - 'PDT', - 'PDU', - 'PE', - 'PE', - 'PEBCAC', - 'PEBKAC', - 'PEEK', - 'PEET', - 'PEGIDA', - 'PEI', - 'PEK', - 'PEN', - 'PEO', - 'PEPFAR', - 'perl', - 'PES', - 'PEST', - 'PET', - 'PETA', - 'PETE', - 'PETP', - 'pF', - 'PF', - 'PFD', - 'PFE', - 'PFLAG', - 'PFIY', - 'PFO', - 'PfP', - 'pg', - 'Pg', - 'PG', - 'PGCE', - 'PGD', - 'PGENI', - 'PGH', - 'PGK', - 'PGM', - 'PGP', - 'pH', - 'PH', - 'PHA', - 'PHL', - 'PHQ', - 'PHP', - 'pi', - 'Pi', - 'PI', - 'PIB', - 'PIC', - 'PICNIC', - 'PICS', - 'PID', - 'PIF', - 'PIG', - 'PIM', - 'PIN', - 'PINF', - 'PIPA', - 'PIR', - 'PIRA', - 'PIREPS', - 'PITA', - 'pJ', - 'PJ', - 'PJHQ', - 'PJs', - 'K', - 'PK', - 'PKA', - 'PKE', - 'PKI', - 'PKM', - 'PKMS', - 'PKMT', - 'PKR', - 'PKT', - 'PKTM', - 'PKU', - 'pl', - 'pL', - 'PL', - 'PLAF', - 'PLAID', - 'PLB', - 'PLC', - 'PLCC', - 'PLD', - 'PLEBS', - 'PLF', - 'PLGR', - 'pli', - 'PLI', - 'PLIF', - 'PLK', - 'PLKK', - 'PLL', - 'PLM', - 'PLMBA', - 'Pln', - 'PLN', - 'PLO', - 'PLOKTA', - 'PLP', - 'PLS', - 'PLUTO', - 'PLW', - 'pm', - 'Pm', - 'PM', - 'PMC', - 'PMCS', - 'PME', - 'PMI', - 'PMID', - 'PMMA', - 'PMOS', - 'PMS', - 'PMSC', - 'PMSL', - 'PMT', - 'pN', - 'PN', - 'PNAC', - 'PNC', - 'PNF', - 'PNG', - 'PNP', - 'PNS', - 'PNVS', - 'PNH', - 'Po', - 'PO', - 'PO', - 'PO', - 'POCD', - 'pod', - 'POD', - 'POE', - 'POETS', - 'POFF', - 'POG', - 'POGO', - 'POI', - 'POK', - 'pol', - 'POL', - 'POLAD', - 'POM', - 'POMDP', - 'POP', - 'por', - 'POR', - 'POS', - 'POSH', - 'POSSLQ', - 'POT', - 'POTS', - 'POV', - 'POW', - 'pp', - 'PP', - 'PPA', - 'PPASC', - 'PPASSCCATAG', - 'PPCLI', - 'PPG', - 'PPL', - 'PPLI', - 'ppm', - 'PPM', - 'PPM', - 'PPO', - 'PPP', - 'PPQ', - 'PPQN', - 'PPQT', - 'PPS', - 'PPV', - 'PQ', - 'Pr', - 'Pr', - 'PR', - 'Prb', - 'PRBO', - 'PRC', - 'PRD', - 'PRFG', - 'PRI', - 'PRK', - 'PRM', - 'PRN', - 'PRT', - 'PRY', - 'ps', - 'pS', - 'Ps', - 'PS', - 'PooS', - 'PSA', - 'PSC', - 'PSE', - 'PSG', - 'PSI', - 'PSI', - 'PSO', - 'PSS', - 'PST', - 'PSU', - 'PSV', - 'PsyOp', - 'pt', - 'pT', - 'Pt', - 'PT', - 'PTA', - 'PTAL', - 'PTAN', - 'PTES', - 'PTFE', - 'PTI', - 'PTL', - 'PTO', - 'PTSD', - 'PTUI', - 'Pu', - 'PU', - 'Pub', - 'PUG', - 'PUN', - 'PUP', - 'PUR', - 'pus', - 'PUS', - 'pV', - 'PV', - 'PVC', - 'PvE', - 'PvP', - 'PVR', - 'PvZ', - 'pW', - 'PW', - 'petawatt', - 'PWA', - 'PWBA', - 'PwC', - 'PWC', - 'PWL', - 'PWLC', - 'PWP', - 'PWR', - 'PWV', - 'PX', - 'PY', - 'PYF', - 'PYG', - 'PYMWYMI', - 'PZ', - 'Q1', - 'Q2', - 'Q3', - 'Q4', - 'QA', - 'Qantas', - 'QAP', - 'QAR', - 'QAT', - 'QB', - 'QC', - 'QCA', - 'QCD', - 'QD', - 'QDOS', - 'QDR', - 'QDRO', - 'QED', - 'QEH', - 'QFE', - 'QID', - 'QIP', - 'QK', - 'QLD', - 'QM', - 'QNH', - 'QPR', - 'QPS', - 'QR', - 'QS', - 'QSCAT', - 'QSL', - 'QSO', - 'QSTAG', - 'QT', - 'qu', - 'Quango', - 'Quasar', - 'QUB', - 'que', - 'QUIET', - 'qv', - 'QVC', - 'QWG', - 'QWGAOR', - 'QZ', - 'R', - 'R2I', - 'R3P', - 'Ra', - 'RA', - 'RA', - 'RAA', - 'RAAF', - 'Rabobank', - 'RAC', - 'RACK', - 'Racon', - 'RADA', - 'Radar', - 'RADB', - 'RADb', - 'RadB', - 'RADC', - 'RAE', - 'RAF', - 'RAG', - 'RAGBRAI', - 'RAM', - 'Ramark', - 'RAOC', - 'RAP', - 'RARDE', - 'RARE', - 'RASC', - 'RATELO', - 'Rb', - 'RB', - 'RBAR', - 'RBCI', - 'RBI', - 'RBOC', - 'RC', - 'RCA', - 'RCAF', - 'RCAR', - 'RCC', - 'RCCO', - 'RCHA', - 'RCMP', - 'RCRA', - 'RCS', - 'RCT', - 'RCZ', - 'RD', - 'RDA', - 'RDD', - 'RDDL', - 'RDECOM', - 'RDM', - 'RDO', - 'RDS', - 'RDT', - 'RDV', - 'Re', - 'RE', - 'REACH', - 'REACT', - 'REAM', - 'RECCE', - 'REEF', - 'REM', - 'REME', - 'RERC', - 'RES', - 'RESNA', - 'REU', - 'Rf', - 'RF', - 'RFA', - 'RFC', - 'RFD', - 'RFE', - 'RFEF', - 'RFF', - 'RFI', - 'RFID', - 'RFL', - 'RFL', - 'RFP', - 'RFQ', - 'RFT', - 'RFU', - 'Rg', - 'RG', - 'RGB', - 'RGBI', - 'Rh', - 'RH', - 'RHIC', - 'RHLI', - 'RHO', - 'RHS', - 'RHU', - 'RHWR', - 'RI', - 'RI', - 'RIAA', - 'RIBA', - 'RIF', - 'RIFA', - 'RILA', - 'RIM', - 'RIMVS', - 'RINO', - 'RIP', - 'RIS', - 'RISC', - 'RISD', - 'RISTA', - 'RWH', - 'RJD', - 'RKBA', - 'RKI', - 'RKO', - 'RL', - 'RLP', - 'RLS', - 'RLY', - 'RM', - 'rm', - 'RMA', - 'RMC', - 'RMC', - 'RMDA', - 'RMP', - 'RMS', - 'rn', - 'Rn', - 'RN', - 'RN', - 'RNA', - 'RNAD', - 'RNPS', - 'RNZ', - 'RNZI', - 'ro', - 'RO', - 'ROA', - 'ROC', - 'ROD', - 'ROE', - 'roh', - 'ROH', - 'ROI', - 'ROFLOL', - 'ROK', - 'ROL', - 'ROM', - 'ron', - 'RON', - 'ROP', - 'ROPE', - 'ROS', - 'ROTC', - 'ROU', - 'ROV', - 'ROW', - 'ROWPU', - 'ROZ', - 'RP', - 'RPA', - 'RPD', - 'RPF', - 'RPG', - 'RPL', - 'rpm', - 'RPM', - 'RPN', - 'RPO', - 'rps', - 'RPV', - 'RQ', - 'RRB', - 'RRN', - 'RRP', - 'RRS', - 'RRSP', - 'RS', - 'RSA', - 'RSD', - 'RSEQ', - 'RSI', - 'RSJ', - 'RSN', - 'RSPB', - 'RSPCA', - 'RSR', - 'RSRE', - 'RSRI', - 'RSS', - 'RSTA', - 'RSVP', - 'RT', - 'RTA', - 'RTBF', - 'RTCA', - 'RTF', - 'RTFM', - 'RTG', - 'RTGS', - 'RTI', - 'RTL', - 'RTLS', - 'RTP', - 'RTS', - 'RTT', - 'RTV', - 'ru', - 'Ru', - 'RU', - 'RUB', - 'RUC', - 'run', - 'rus', - 'RUS', - 'RUSI', - 'RV', - 'RVD', - 'rw', - 'RW', - 'RWA', - 'RWADA', - 'RWF', - 'RWR', - 'RWS', - 'Rx', - 'RX', - 'South', - 'SA', - 'SAA', - 'SAC', - 'SAD', - 'SADL', - 'SAE', - 'SALT', - 'SAM', - 'Sambo', - 'san', - 'SAR', - 'SARA', - 'SART', - 'SAS', - 'SAT', - 'SAW', - 'SB', - 'SBA', - 'SBC', - 'SBD', - 'SBNO', - 'SBP', - 'SBS', - 'SBW', - 'SC', - 'SCA', - 'SCO', - 'SCS', - 'SCSI', - 'SD', - 'SDC', - 'SDHC', - 'SDXC', - 'SE', - 'SEC', - 'SEM', - 'SEN', - 'SEP', - 'SEU', - 'SF', - 'SFA', - 'SFE', - 'SG', - 'SGC', - 'SGS', - 'SH', - 'SHAC', - 'SHO', - 'SI', - 'SIG', - 'SIIT', - 'SIMPLE', - 'SIP', - 'Siri', - 'SIRI', - 'SISO', - 'SJ', - 'SK', - 'SL', - 'SLA', - 'SLOC', - 'SLP', - 'SLT', - 'SM', - 'SME', - 'SMG', - 'SMX', - 'SMZ', - 'SN', - 'SNL', - 'SNR', - 'SO', - 'SOA', - 'SOAP', - 'SOB', - 'SoCon', - 'SOD', - 'SOE', - 'SOF', - 'SOGAT', - 'SOM', - 'SOP', - 'SOR', - 'SOS', - 'Sp', - 'SP', - 'SPD', - 'SPL', - 'SR', - 'SRAM', - 'SRD', - 'SRG', - 'SRL', - 'SS', - 'SSA', - 'SSC', - 'SSD', - 'SSDD', - 'SSE', - 'SSN', - 'SSR', - 'SSRI', - 'SSRN', - 'SSS', - 'SST', - 'ST', - 'STA', - 'STAR', - 'STAT', - 'STB', - 'STD', - 'STFU', - 'STI', - 'STP', - 'STRICOM', - 'SU', - 'SUI', - 'SV', - 'SVD', - 'SW', - 'SWA', - 'SWF', - 'SWG', - 'SWLABR', - 'SWP', - 'SXM', - 'SZ', - 'T', - 'ta', - 'Ta', - 'TA', - 'TAA', - 'TAACOM', - 'TAADCOORD', - 'TAAR', - 'TAC', - 'TACAMO', - 'TACAN', - 'TACC', - 'TACCO', - 'TACCSF', - 'TACDAR', - 'TACP', - 'TACSOP', - 'TADIL', - 'TADL', - 'TADSS', - 'tah', - 'TAI', - 'TAJ', - 'TAJT', - 'TALES', - 'tam', - 'TAN', - 'TANJ', - 'TANP', - 'TANSTAAFL', - 'TAPPS', - 'TARDIS', - 'TARP', - 'TAROT', - 'TAS', - 'TASER', - 'TASM', - 'tat', - 'TAT', - 'TATP', - 'TATT', - 'Tb', - 'TB', - 'TBA', - 'TBBPA', - 'TBC', - 'TBD', - 'TBD', - 'TBF', - 'TBGRI', - 'TBH', - 'TBL', - 'TBM', - 'TBS', - 'TBT', - 'Tc', - 'TC', - 'TCA', - 'TCAS', - 'TCAA', - 'TCCCS', - 'TCD', - 'TCG', - 'TCHD', - 'TCL', - 'TCM', - 'TCP', - 'TCU', - 'TCWMS', - 'TCW', - 'TD', - 'TDA', - 'TDL', - 'TDM', - 'TDMA', - 'TDP', - 'TDRS', - 'te', - 'Te', - 'TE', - 'TEAL', - 'Team', - 'TEAMS', - 'TED', - 'TEETH', - 'tel', - 'TEL', - 'TELAR', - 'TEMO', - 'TENCAP', - 'TERS', - 'TES', - 'TEST', - 'TEU', - 'TeVeS', - 'TF', - 'TFMPP', - 'TFR', - 'TFT', - 'tg', - 'Tg', - 'TG', - 'TGA', - 'TGB', - 'TGIF', - 'tgk', - 'tgl', - 'TGO', - 'TGT', - 'TGV', - 'th', - 'Th', - 'TH', - 'tha', - 'THA', - 'THAAD', - 'THB', - 'THEGENTRY', - 'THEL', - 'THG', - 'ti', - 'Ti', - 'TI', - 'TIA', - 'TIA', - 'TIALD', - 'TIBS', - 'TIC', - 'TID', - 'TIGERS', - 'TIM', - 'TIMS', - 'TINC', - 'TINY', - 'TIP', - 'tir', - 'tits', - 'TJ', - 'TJD', - 'TJK', - 'TJS', - 'tk', - 'TK', - 'TKBL', - 'TKL', - 'TKM', - 'tl', - 'Tl', - 'TLPM', - 'TL', - 'TLATOJL', - 'TLA', - 'TLAM', - 'TLAR', - 'TLC', - 'TLD', - 'TLM', - 'TLS', - 'Tm', - 'Thulium', - 'TM', - 'TMA', - 'TMAP', - 'TMC', - 'TMD', - 'TMDK', - 'TMI', - 'TMJ', - 'TMM', - 'TMNT', - 'TMO', - 'TMP', - 'TMPG', - 'TMR', - 'TMS', - 'TMT', - 'TMTOWTDI', - 'TMZ', - 'tn', - 'TN', - 'TNA', - 'TND', - 'TNO', - 'TNT', - 'to', - 'TOA', - 'TOAD', - 'TOBS', - 'TOC', - 'TOG', - 'TOGS', - 'TOMS', - 'ton', - 'TON', - 'TOP', - 'TOS', - 'TOT', - 'TOW', - 'TP', - 'TPE', - 'TPI', - 'TPO', - 'TPS', - 'TPTB', - 'TQ', - 'tr', - 'TR', - 'TRA', - 'TRAC', - 'TRACON', - 'TRADOC', - 'TRAIN', - 'TRANSCOM', - 'TRAP', - 'TRASANA', - 'TRD', - 'TRIGAT', - 'TRL', - 'Trp', - 'TRP', - 'TRU', - 'TRUEX', - 'TRW', - 'TRY', - 'ts', - 'Ts', - 'TS', - 'TSA', - 'TSCA', - 'TSEC', - 'TSL', - 'tsn', - 'TSN', - 'tso', - 'TSO', - 'TSOP', - 'TSR', - 'TSSIA', - 'tt', - 'TT', - 'TTCT', - 'TTD', - 'TTO', - 'TTFN', - 'TTL', - 'TTP', - 'TTTEAF', - 'TTY', - 'TTYL', - 'TTZ', - 'TU', - 'TUC', - 'TUG', - 'TUI', - 'tuk', - 'TUN', - 'TUNS', - 'Tur', - 'TUR', - 'TUSK', - 'TUV', - 'Tv', - 'TV', - 'TVA', - 'TVG', - 'TVR', - 'TVS', - 'tw', - 'TW', - 'TWAIN', - 'TWD', - 'TWE', - 'twi', - 'TWI', - 'TWN', - 'TWP', - 'TWS', - 'TWIP', - 'TX', - 'TXV', - 'ty', - 'TYT', - 'TZ', - 'TZA', - 'TZC', - 'TZS', - 'U', - 'UA', - 'UAAP', - 'UAB', - 'UAE', - 'UAH', - 'UAR', - 'UAV', - 'UBC', - 'UBIGEO', - 'UBS', - 'UCBSA', - 'UCAV', - 'UCF', - 'UCI', - 'UCLA', - 'UCLES', - 'UCR', - 'UCS', - 'UCSB', - 'UCSD', - 'UDAS', - 'UDDI', - 'UDP', - 'UDT', - 'UDTS', - 'UE', - 'UEFA', - 'UEFI', - 'UESAC', - 'UFC', - 'UFO', - 'ug', - 'UG', - 'UGA', - 'UGV', - 'UGX', - 'UHF', - 'UHMW', - 'UHRB', - 'UIC', - 'UID', - 'uig', - 'UIL', - 'UIUC', - 'UJTL', - 'uk', - 'UK', - 'UKIP', - 'ukr', - 'UKR', - 'UL', - 'ULEB', - 'ULF', - 'UM', - 'UMBC', - 'UMC', - 'UMCP', - 'UMES', - 'UMI', - 'UMIST', - 'UMK', - 'UML', - 'UMTS', - 'UN', - 'UNABOM', - 'UNAIDS', - 'UNaM', - 'UNAM', - 'UNAMSIL', - 'UNCF', - 'UNCLOS', - 'UNCSGN', - 'UNCTAD', - 'UNDCP', - 'UNDE', - 'UNDP', - 'UNEP', - 'UNESCO', - 'UNFCCC', - 'UNFICYP', - 'UNGEGN', - 'UNHCR', - 'UNICEF', - 'UNICRI', - 'UNIDO', - 'UNIFEM', - 'UNIS', - 'UNLV', - 'UNMIK', - 'UNMOVIC', - 'UNOSOM', - 'UNPROFOR', - 'UNRISD', - 'UNSM', - 'UNTAC', - 'UNTAET', - 'UNU', - 'UP', - 'UPC', - 'UPdM', - 'UPDM', - 'UPF', - 'UPI', - 'UPN', - 'UPS', - 'UPTN', - 'UPU', - 'ur', - 'UR', - 'URBSFA', - 'urd', - 'URD', - 'URI', - 'URL', - 'URU', - 'URY', - 'US', - 'USA', - 'USAADAC', - 'USAARMC', - 'USAAVNC', - 'USAF', - 'USAFSPC', - 'USAIC', - 'USAID', - 'USAP', - 'USAPATRIOT', - 'USAREUR', - 'USB', - 'USC', - 'USCG', - 'USCINCSPACE', - 'USD', - 'USDA', - 'USDI', - 'USDOE', - 'USDRE', - 'USEC', - 'USFL', - 'USFWS', - 'USGS', - 'USIA', - 'USJFCOM', - 'USL', - 'USMC', - 'USMNT', - 'USN', - 'USNA', - 'USNB', - 'USNG', - 'USNH', - 'USNI', - 'USNO', - 'USNOFS', - 'USNP', - 'USNR', - 'USNS', - 'USNY', - 'USNZ', - 'USPS', - 'USPTO', - 'USR', - 'USS', - 'USSPACECOM', - 'USSR', - 'UST', - 'USTRANSCOM', - 'USWNT', - 'UT', - 'UTEP', - 'UTM', - 'UU', - 'UUA', - 'UUID', - 'UUV', - 'UV', - 'UVM', - 'UW', - 'UWO', - 'UWT', - 'UWTG', - 'UWW', - 'UXO', - 'UY', - 'UYU', - 'uz', - 'UZ', - 'uzb', - 'UZB', - 'UZS', - 'V', - 'VA', - 'VACIS', - 'VACIS', - 'VANOS', - 'VAO', - 'VASIS', - 'VAT', - 'VB', - 'VBA', - 'VC', - 'VCDS', - 'vCJD', - 'VCJCS', - 'VCNO', - 'VCR', - 'VCofS', - 'VCT', - 'VD', - 'VDFS', - 'VDMOS', - 'VDOT', - 'VDR', - 've', - 'VE', - 'VEB', - 'VEEGA', - 'ven', - 'VEN', - 'VERDI', - 'VERITAS', - 'VFA', - 'VfD', - 'VFL', - 'VFM', - 'VFMO', - 'VFR', - 'VFT', - 'VFW', - 'VG', - 'VGB', - 'VH1', - 'VHF', - 'vi', - 'VI', - 'VIA', - 'VIC', - 'VIDS', - 'vie', - 'VII', - 'VIII', - 'VIN', - 'VIP', - 'VIR', - 'VIS', - 'VISCII', - 'VIT', - 'VIVO', - 'VJ', - 'VK', - 'VLA', - 'VLAD', - 'VLBA', - 'VLF', - 'VLIW', - 'VLPFC', - 'VLPMC', - 'VLSF', - 'VLSI', - 'VLVS', - 'VM', - 'VMASC', - 'VMF', - 'VMF', - 'VMN', - 'VN', - 'VND', - 'VNIR', - 'VNM', - 'VNS', - 'VOA', - 'vo', - 'VOC', - 'VOIP', - 'vol', - 'VOLT', - 'Volvo', - 'VON', - 'VOR', - 'VOR', - 'VOX', - 'VP', - 'VPN', - 'VQ', - 'VR', - 'VRSG', - 'VS', - 'VSAT', - 'VSHORAD', - 'VSO', - 'VSOP', - 'VSR', - 'VT', - 'VTC', - 'VTEC', - 'VTOL', - 'VU', - 'VUB', - 'VUT', - 'VUV', - 'VV', - 'VVAF', - 'VW', - 'VZ', - 'vzw', - 'W', - 'W3C', - 'wa', - 'WA', - 'WAAC', - 'WAAF', - 'WAC', - 'WACC', - 'WACS', - 'WAD', - 'WAGGGS', - 'WAGs', - 'WAK', - 'WAL', - 'WAN', - 'WAP', - 'WAPIMA', - 'WAR', - 'WAsP', - 'WASP', - 'WAYP', - 'Wb', - 'WB', - 'WBA', - 'WBC', - 'WBE', - 'WBO', - 'WC', - 'WCBO', - 'WCBP', - 'WCL', - 'WCS', - 'WCW', - 'WDC', - 'WDWR', - 'WDIN', - 'WEBS', - 'WEC', - 'WEEE', - 'WEF', - 'WEM', - 'WEU', - 'WEZ', - 'WF', - 'WFC', - 'WFD', - 'WFF', - 'WFP', - 'WFZ', - 'WFI', - 'WG', - 'WH', - 'WHAM', - 'WHI', - 'WHIP', - 'WHO', - 'WHS', - 'WI', - 'WIA', - 'WIIFM', - 'WIMP', - 'WIPO', - 'WIPP', - 'WIYN', - 'WK', - 'WKYP', - 'WL', - 'WLB', - 'WLF', - 'wln', - 'WLOG', - 'WM', - 'WMANUS', - 'WMD', - 'WMI', - 'WMO', - 'WNA', - 'WNF', - 'WNL', - 'WNIT', - 'WNSD', - 'WNW', - 'wo', - 'WO', - 'WOF', - 'WOFT', - 'WOFTAM', - 'WOI', - 'wol', - 'WOM', - 'WOS', - 'WOSM', - 'WOT', - 'WP', - 'WPB', - 'WPE', - 'WPK', - 'WPT', - 'WR', - 'WRAC', - 'WRAIR', - 'WRBWWRM', - 'WRC', - 'WRC', - 'WRNS', - 'WRVS', - 'WRS', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WRT', - 'WS', - 'WSC', - 'WSDL', - 'WSJ', - 'WSL', - 'WSM', - 'WSMR', - 'WSROC', - 'WSSM', - 'WST', - 'WSTC', - 'WSW', - 'WTB', - 'WTB', - 'WTC', - 'WTF', - 'WTFH', - 'WTH', - 'WTO', - 'WTS', - 'WU', - 'WUU2', - 'WUBU2', - 'WV', - 'WVA', - 'WVIAC', - 'WVR', - 'WVSC', - 'WVU', - 'WVUIT', - 'WW', - 'WWE', - 'WWF', - 'WWI', - 'WWII', - 'WWIII', - 'WWJD', - 'WWS', - 'WWV', - 'WWW', - 'WWWF', - 'WXO', - 'WY', - 'WYSIWYG', - 'WYSIWYP', - 'WZ', - 'XAF', - 'XAG', - 'XAU', - 'XBA', - 'XBB', - 'XBML', - 'XC', - 'XCBL', - 'XCD', - 'XD', - 'Xe', - 'XF', - 'XFA', - 'XFC', - 'XFD', - 'Xfe', - 'XFF', - 'XFG', - 'XFH', - 'XFN', - 'XFR', - 'XFT', - 'XFW', - 'XG', - 'XGA', - 'XGE', - 'XGG', - 'XGN', - 'XGP', - 'XGR', - 'xh', - 'xho', - 'XHTML', - 'XHR', - 'XI', - 'XII', - 'XIII', - 'XIV', - 'XIX', - 'XJ', - 'XJL', - 'XJS', - 'XK', - 'XKA', - 'XKB', - 'XKE', - 'XKH', - 'XKR', - 'XKS', - 'XKY', - 'XL', - 'XML', - 'XMSF', - 'XN', - 'XNA', - 'XNC', - 'XNN', - 'XNS', - 'XNU', - 'XO', - 'XOF', - 'XP', - 'XPD', - 'XPF', - 'XPT', - 'XQJ', - 'XQP', - 'XQU', - 'XRML', - 'XS', - 'XSL', - 'XTMH', - 'XUL', - 'XV', - 'XVI', - 'XVII', - 'XVIII', - 'XWB', - 'XWF', - 'XWM', - 'XWN', - 'XWS', - 'XX', - 'XXX', - 'XYA', - 'Xyl', - 'XZA', - 'XZN', - 'y', - 'Y', - 'Y2K', - 'yA', - 'Ya', - 'YA', - 'YAC', - 'YAFLA', - 'YAP', - 'YaBPr', - 'YABSLAG', - 'Yb', - 'YB', - 'yC', - 'YC', - 'YD', - 'YDS', - 'YE', - 'YEM', - 'YER', - 'yF', - 'YF', - 'yg', - 'Yg', - 'Ygo', - 'YGCO', - 'yH', - 'YH', - 'YHS', - 'YHBT', - 'YHIH1', - 'YHII', - 'yi', - 'Yi', - 'YI', - 'yid', - 'Yippie', - 'yJ', - 'YJ', - 'yK', - 'YK', - 'YKYFCW', - 'yL', - 'YL', - 'ym', - 'Ym', - 'YM', - 'YMCA', - 'YMD', - 'YMMD', - 'YMMV', - 'yN', - 'YN', - 'yo', - 'YO', - 'YOLO', - 'yor', - 'YPA', - 'YPG', - 'YPIO', - 'YPO', - 'ys', - 'yS', - 'Ys', - 'YS', - 'YSA', - 'YSI', - 'yT', - 'YT', - 'YTD', - 'YTM', - 'YTS', - 'YTT', - 'YU', - 'YUG', - 'Yuppie', - 'yV', - 'YV', - 'yW', - 'YW', - 'YWCA', - 'YWMV', - 'YYH', - 'z', - 'Z', - 'za', - 'zA', - 'ZA', - 'ZAF', - 'ZAMS', - 'ZAR', - 'ZAS', - 'ZBT', - 'zC', - 'ZC', - 'ZCM', - 'ZCMK', - 'ZCU', -]; diff --git a/frontend/src/app/consts/code-snippets.ts b/frontend/src/app/consts/code-snippets.ts deleted file mode 100644 index 7285deef1..000000000 --- a/frontend/src/app/consts/code-snippets.ts +++ /dev/null @@ -1,211 +0,0 @@ -export function codeSnippets(signingKey: string) { - return { - cs: { - langName: 'C#', - mode: 'csharp', - snippet: `using System; -using System.Security.Cryptography; -using System.Text; -using Microsoft.AspNetCore.Mvc; - -[Route("rocketadmin")] -[ApiController] -public class RocketAdminController : ControllerBase -{ - [HttpPost] - public IActionResult Post([FromBody] dynamic bodyData) - { - string rocketadminSignature = Request.Headers["rocketadmin-signature"]; - - string bodyToJson = Newtonsoft.Json.JsonConvert.SerializeObject(bodyData); - - var hmac = new HMACSHA256(Encoding.UTF8.GetBytes("${signingKey}")); - byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(bodyToJson)); - - string hashString = BitConverter.ToString(hash).Replace("-", "").ToLower(); - - if (hashString == rocketadminSignature) - { - // your code - return Ok(); - } - else - { - return BadRequest("Signature invalid"); - } - } -} -`, - }, - - java: { - langName: 'Java', - mode: 'java', - snippet: `import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; -import java.util.Map; - -@RestController -@RequestMapping("/rocketadmin") -public class RocketAdminController { - - @PostMapping - public ResponseEntity handlePost(@RequestBody Map bodyData, - @RequestHeader("rocketadmin-signature") String rocketadminSignature) throws NoSuchAlgorithmException, InvalidKeyException { - String bodyToJson = new ObjectMapper().writeValueAsString(bodyData); - - SecretKeySpec secretKeySpec = new SecretKeySpec("${signingKey}".getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(secretKeySpec); - byte[] hash = mac.doFinal(bodyToJson.getBytes(StandardCharsets.UTF_8)); - - String hashString = Base64.getEncoder().encodeToString(hash); - - if (MessageDigest.isEqual(hashString.getBytes(StandardCharsets.UTF_8), rocketadminSignature.getBytes(StandardCharsets.UTF_8))) { - // your code - return ResponseEntity.ok().build(); - } else { - return ResponseEntity.status(400).body("Signature invalid"); - } - } -} -`, - }, - - php: { - langName: 'PHP', - mode: 'php', - snippet: String.raw`header('rocketadmin-signature'); - $bodyData = $request->all(); - - $bodyToJson = json_encode($bodyData); - - $hmac = hash_hmac('sha256', $bodyToJson, '${signingKey}'); - - if (hash_equals($hmac, $rocketadminSignature)) { - // your code - return response()->json([], 200); - } else { - return response()->json(['error' => 'Signature invalid'], 400); - } - } -}`, - }, - - go: { - langName: 'Go', - mode: 'go', - snippet: `package main -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io/ioutil" - "net/http" -) - -func main() { - http.HandleFunc("/rocketadmin", func(w http.ResponseWriter, r *http.Request) { - rocketadminSignature := r.Header.Get("rocketadmin-signature") - - body, _ := ioutil.ReadAll(r.Body) - var bodyData map[string]interface{} - json.Unmarshal(body, &bodyData) - - bodyToJson, _ := json.Marshal(bodyData) - - h := hmac.New(sha256.New, []byte("${signingKey}")) - h.Write(bodyToJson) - hash := hex.EncodeToString(h.Sum(nil)) - - if hmac.Equal([]byte(hash), []byte(rocketadminSignature)) { - // your code - w.WriteHeader(http.StatusOK) - } else { - http.Error(w, "Signature invalid", http.StatusBadRequest) - } - }) - - http.ListenAndServe(":8080", nil) -} -`, - }, - - python: { - langName: 'Python', - mode: 'python', - snippet: `from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.utils.crypto import constant_time_compare -import hashlib -import hmac -import json - -@csrf_exempt -def rocketadmin(request): - if request.method == 'POST': - rocketadmin_signature = request.META.get('HTTP_ROCKETADMIN_SIGNATURE') - body_data = json.loads(request.body) - - body_to_json = json.dumps(body_data) - - hmac_obj = hmac.new(b'${signingKey}', body_to_json.encode(), hashlib.sha256) - hash = hmac_obj.hexdigest() - - if constant_time_compare(hash, rocketadmin_signature): - # your code - return JsonResponse({}, status=200) - else: - return JsonResponse({'error': 'Signature invalid'}, status=400) -`, - }, - - node: { - langName: 'Node.js', - mode: 'javascript', - snippet: `const crypto = require("crypto"); - -app.use(express.json()); - -router.post("/rocketadmin", (req, res) => { - const rocketadminSignature = req.headers["rocketadmin-signature"]; - const bodyData = req.body; - - const bodyToJson = JSON.stringify(bodyData); - - const hmac = crypto.createHmac("sha256", "${signingKey}"); - - hmac.update(bodyToJson); - const hash = hmac.digest("hex"); - - if ( - crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(rocketadminSignature)) - ) { - //your code - } else { - res.status(400).send("Signature invalid"); - } -}); -`, - }, - }; -} diff --git a/frontend/src/app/consts/countries.ts b/frontend/src/app/consts/countries.ts deleted file mode 100644 index 90b9936ba..000000000 --- a/frontend/src/app/consts/countries.ts +++ /dev/null @@ -1,266 +0,0 @@ -export interface Country { - code: string; - name: string; - dialCode?: string; -} - -export function getCountryFlag(countryCode: string): string { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -} - -export const COUNTRIES: Country[] = [ - { code: 'AD', name: 'Andorra', dialCode: '+376' }, - { code: 'AE', name: 'United Arab Emirates', dialCode: '+971' }, - { code: 'AF', name: 'Afghanistan', dialCode: '+93' }, - { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268' }, - { code: 'AI', name: 'Anguilla', dialCode: '+1264' }, - { code: 'AL', name: 'Albania', dialCode: '+355' }, - { code: 'AM', name: 'Armenia', dialCode: '+374' }, - { code: 'AO', name: 'Angola', dialCode: '+244' }, - { code: 'AQ', name: 'Antarctica', dialCode: '+672' }, - { code: 'AR', name: 'Argentina', dialCode: '+54' }, - { code: 'AS', name: 'American Samoa', dialCode: '+1684' }, - { code: 'AT', name: 'Austria', dialCode: '+43' }, - { code: 'AU', name: 'Australia', dialCode: '+61' }, - { code: 'AW', name: 'Aruba', dialCode: '+297' }, - { code: 'AX', name: 'Åland Islands', dialCode: '+358' }, - { code: 'AZ', name: 'Azerbaijan', dialCode: '+994' }, - { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387' }, - { code: 'BB', name: 'Barbados', dialCode: '+1246' }, - { code: 'BD', name: 'Bangladesh', dialCode: '+880' }, - { code: 'BE', name: 'Belgium', dialCode: '+32' }, - { code: 'BF', name: 'Burkina Faso', dialCode: '+226' }, - { code: 'BG', name: 'Bulgaria', dialCode: '+359' }, - { code: 'BH', name: 'Bahrain', dialCode: '+973' }, - { code: 'BI', name: 'Burundi', dialCode: '+257' }, - { code: 'BJ', name: 'Benin', dialCode: '+229' }, - { code: 'BL', name: 'Saint Barthélemy', dialCode: '+590' }, - { code: 'BM', name: 'Bermuda', dialCode: '+1441' }, - { code: 'BN', name: 'Brunei Darussalam', dialCode: '+673' }, - { code: 'BO', name: 'Bolivia', dialCode: '+591' }, - { code: 'BQ', name: 'Bonaire, Sint Eustatius and Saba', dialCode: '+599' }, - { code: 'BR', name: 'Brazil', dialCode: '+55' }, - { code: 'BS', name: 'Bahamas', dialCode: '+1242' }, - { code: 'BT', name: 'Bhutan', dialCode: '+975' }, - { code: 'BV', name: 'Bouvet Island', dialCode: '+47' }, - { code: 'BW', name: 'Botswana', dialCode: '+267' }, - { code: 'BY', name: 'Belarus', dialCode: '+375' }, - { code: 'BZ', name: 'Belize', dialCode: '+501' }, - { code: 'CA', name: 'Canada', dialCode: '+1' }, - { code: 'CC', name: 'Cocos (Keeling) Islands', dialCode: '+61' }, - { code: 'CD', name: 'Congo, Democratic Republic of the', dialCode: '+243' }, - { code: 'CF', name: 'Central African Republic', dialCode: '+236' }, - { code: 'CG', name: 'Congo', dialCode: '+242' }, - { code: 'CH', name: 'Switzerland', dialCode: '+41' }, - { code: 'CI', name: "Côte d'Ivoire", dialCode: '+225' }, - { code: 'CK', name: 'Cook Islands', dialCode: '+682' }, - { code: 'CL', name: 'Chile', dialCode: '+56' }, - { code: 'CM', name: 'Cameroon', dialCode: '+237' }, - { code: 'CN', name: 'China', dialCode: '+86' }, - { code: 'CO', name: 'Colombia', dialCode: '+57' }, - { code: 'CR', name: 'Costa Rica', dialCode: '+506' }, - { code: 'CU', name: 'Cuba', dialCode: '+53' }, - { code: 'CV', name: 'Cape Verde', dialCode: '+238' }, - { code: 'CW', name: 'Curaçao', dialCode: '+599' }, - { code: 'CX', name: 'Christmas Island', dialCode: '+61' }, - { code: 'CY', name: 'Cyprus', dialCode: '+357' }, - { code: 'CZ', name: 'Czech Republic', dialCode: '+420' }, - { code: 'DE', name: 'Germany', dialCode: '+49' }, - { code: 'DJ', name: 'Djibouti', dialCode: '+253' }, - { code: 'DK', name: 'Denmark', dialCode: '+45' }, - { code: 'DM', name: 'Dominica', dialCode: '+1767' }, - { code: 'DO', name: 'Dominican Republic', dialCode: '+1' }, - { code: 'DZ', name: 'Algeria', dialCode: '+213' }, - { code: 'EC', name: 'Ecuador', dialCode: '+593' }, - { code: 'EE', name: 'Estonia', dialCode: '+372' }, - { code: 'EG', name: 'Egypt', dialCode: '+20' }, - { code: 'EH', name: 'Western Sahara', dialCode: '+212' }, - { code: 'ER', name: 'Eritrea', dialCode: '+291' }, - { code: 'ES', name: 'Spain', dialCode: '+34' }, - { code: 'ET', name: 'Ethiopia', dialCode: '+251' }, - { code: 'FI', name: 'Finland', dialCode: '+358' }, - { code: 'FJ', name: 'Fiji', dialCode: '+679' }, - { code: 'FK', name: 'Falkland Islands (Malvinas)', dialCode: '+500' }, - { code: 'FM', name: 'Micronesia, Federated States of', dialCode: '+691' }, - { code: 'FO', name: 'Faroe Islands', dialCode: '+298' }, - { code: 'FR', name: 'France', dialCode: '+33' }, - { code: 'GA', name: 'Gabon', dialCode: '+241' }, - { code: 'GB', name: 'United Kingdom', dialCode: '+44' }, - { code: 'GD', name: 'Grenada', dialCode: '+1473' }, - { code: 'GE', name: 'Georgia', dialCode: '+995' }, - { code: 'GF', name: 'French Guiana', dialCode: '+594' }, - { code: 'GG', name: 'Guernsey', dialCode: '+44' }, - { code: 'GH', name: 'Ghana', dialCode: '+233' }, - { code: 'GI', name: 'Gibraltar', dialCode: '+350' }, - { code: 'GL', name: 'Greenland', dialCode: '+299' }, - { code: 'GM', name: 'Gambia', dialCode: '+220' }, - { code: 'GN', name: 'Guinea', dialCode: '+224' }, - { code: 'GP', name: 'Guadeloupe', dialCode: '+590' }, - { code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240' }, - { code: 'GR', name: 'Greece', dialCode: '+30' }, - { code: 'GS', name: 'South Georgia and the South Sandwich Islands', dialCode: '+500' }, - { code: 'GT', name: 'Guatemala', dialCode: '+502' }, - { code: 'GU', name: 'Guam', dialCode: '+1671' }, - { code: 'GW', name: 'Guinea-Bissau', dialCode: '+245' }, - { code: 'GY', name: 'Guyana', dialCode: '+592' }, - { code: 'HK', name: 'Hong Kong', dialCode: '+852' }, - { code: 'HM', name: 'Heard Island and McDonald Islands', dialCode: '+672' }, - { code: 'HN', name: 'Honduras', dialCode: '+504' }, - { code: 'HR', name: 'Croatia', dialCode: '+385' }, - { code: 'HT', name: 'Haiti', dialCode: '+509' }, - { code: 'HU', name: 'Hungary', dialCode: '+36' }, - { code: 'ID', name: 'Indonesia', dialCode: '+62' }, - { code: 'IE', name: 'Ireland', dialCode: '+353' }, - { code: 'IL', name: 'Israel', dialCode: '+972' }, - { code: 'IM', name: 'Isle of Man', dialCode: '+44' }, - { code: 'IN', name: 'India', dialCode: '+91' }, - { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246' }, - { code: 'IQ', name: 'Iraq', dialCode: '+964' }, - { code: 'IR', name: 'Iran, Islamic Republic of', dialCode: '+98' }, - { code: 'IS', name: 'Iceland', dialCode: '+354' }, - { code: 'IT', name: 'Italy', dialCode: '+39' }, - { code: 'JE', name: 'Jersey', dialCode: '+44' }, - { code: 'JM', name: 'Jamaica', dialCode: '+1876' }, - { code: 'JO', name: 'Jordan', dialCode: '+962' }, - { code: 'JP', name: 'Japan', dialCode: '+81' }, - { code: 'KE', name: 'Kenya', dialCode: '+254' }, - { code: 'KG', name: 'Kyrgyzstan', dialCode: '+996' }, - { code: 'KH', name: 'Cambodia', dialCode: '+855' }, - { code: 'KI', name: 'Kiribati', dialCode: '+686' }, - { code: 'KM', name: 'Comoros', dialCode: '+269' }, - { code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869' }, - { code: 'KP', name: "Korea, Democratic People's Republic of", dialCode: '+850' }, - { code: 'KR', name: 'Korea, Republic of', dialCode: '+82' }, - { code: 'KW', name: 'Kuwait', dialCode: '+965' }, - { code: 'KY', name: 'Cayman Islands', dialCode: '+1345' }, - { code: 'KZ', name: 'Kazakhstan', dialCode: '+7' }, - { code: 'LA', name: "Lao People's Democratic Republic", dialCode: '+856' }, - { code: 'LB', name: 'Lebanon', dialCode: '+961' }, - { code: 'LC', name: 'Saint Lucia', dialCode: '+1758' }, - { code: 'LI', name: 'Liechtenstein', dialCode: '+423' }, - { code: 'LK', name: 'Sri Lanka', dialCode: '+94' }, - { code: 'LR', name: 'Liberia', dialCode: '+231' }, - { code: 'LS', name: 'Lesotho', dialCode: '+266' }, - { code: 'LT', name: 'Lithuania', dialCode: '+370' }, - { code: 'LU', name: 'Luxembourg', dialCode: '+352' }, - { code: 'LV', name: 'Latvia', dialCode: '+371' }, - { code: 'LY', name: 'Libya', dialCode: '+218' }, - { code: 'MA', name: 'Morocco', dialCode: '+212' }, - { code: 'MC', name: 'Monaco', dialCode: '+377' }, - { code: 'MD', name: 'Moldova, Republic of', dialCode: '+373' }, - { code: 'ME', name: 'Montenegro', dialCode: '+382' }, - { code: 'MF', name: 'Saint Martin (French part)', dialCode: '+590' }, - { code: 'MG', name: 'Madagascar', dialCode: '+261' }, - { code: 'MH', name: 'Marshall Islands', dialCode: '+692' }, - { code: 'MK', name: 'North Macedonia', dialCode: '+389' }, - { code: 'ML', name: 'Mali', dialCode: '+223' }, - { code: 'MM', name: 'Myanmar', dialCode: '+95' }, - { code: 'MN', name: 'Mongolia', dialCode: '+976' }, - { code: 'MO', name: 'Macao', dialCode: '+853' }, - { code: 'MP', name: 'Northern Mariana Islands', dialCode: '+1670' }, - { code: 'MQ', name: 'Martinique', dialCode: '+596' }, - { code: 'MR', name: 'Mauritania', dialCode: '+222' }, - { code: 'MS', name: 'Montserrat', dialCode: '+1664' }, - { code: 'MT', name: 'Malta', dialCode: '+356' }, - { code: 'MU', name: 'Mauritius', dialCode: '+230' }, - { code: 'MV', name: 'Maldives', dialCode: '+960' }, - { code: 'MW', name: 'Malawi', dialCode: '+265' }, - { code: 'MX', name: 'Mexico', dialCode: '+52' }, - { code: 'MY', name: 'Malaysia', dialCode: '+60' }, - { code: 'MZ', name: 'Mozambique', dialCode: '+258' }, - { code: 'NA', name: 'Namibia', dialCode: '+264' }, - { code: 'NC', name: 'New Caledonia', dialCode: '+687' }, - { code: 'NE', name: 'Niger', dialCode: '+227' }, - { code: 'NF', name: 'Norfolk Island', dialCode: '+672' }, - { code: 'NG', name: 'Nigeria', dialCode: '+234' }, - { code: 'NI', name: 'Nicaragua', dialCode: '+505' }, - { code: 'NL', name: 'Netherlands', dialCode: '+31' }, - { code: 'NO', name: 'Norway', dialCode: '+47' }, - { code: 'NP', name: 'Nepal', dialCode: '+977' }, - { code: 'NR', name: 'Nauru', dialCode: '+674' }, - { code: 'NU', name: 'Niue', dialCode: '+683' }, - { code: 'NZ', name: 'New Zealand', dialCode: '+64' }, - { code: 'OM', name: 'Oman', dialCode: '+968' }, - { code: 'PA', name: 'Panama', dialCode: '+507' }, - { code: 'PE', name: 'Peru', dialCode: '+51' }, - { code: 'PF', name: 'French Polynesia', dialCode: '+689' }, - { code: 'PG', name: 'Papua New Guinea', dialCode: '+675' }, - { code: 'PH', name: 'Philippines', dialCode: '+63' }, - { code: 'PK', name: 'Pakistan', dialCode: '+92' }, - { code: 'PL', name: 'Poland', dialCode: '+48' }, - { code: 'PM', name: 'Saint Pierre and Miquelon', dialCode: '+508' }, - { code: 'PN', name: 'Pitcairn', dialCode: '+64' }, - { code: 'PR', name: 'Puerto Rico', dialCode: '+1787' }, - { code: 'PS', name: 'Palestine, State of', dialCode: '+970' }, - { code: 'PT', name: 'Portugal', dialCode: '+351' }, - { code: 'PW', name: 'Palau', dialCode: '+680' }, - { code: 'PY', name: 'Paraguay', dialCode: '+595' }, - { code: 'QA', name: 'Qatar', dialCode: '+974' }, - { code: 'RE', name: 'Réunion', dialCode: '+262' }, - { code: 'RO', name: 'Romania', dialCode: '+40' }, - { code: 'RS', name: 'Serbia', dialCode: '+381' }, - { code: 'RU', name: 'Russian Federation', dialCode: '+7' }, - { code: 'RW', name: 'Rwanda', dialCode: '+250' }, - { code: 'SA', name: 'Saudi Arabia', dialCode: '+966' }, - { code: 'SB', name: 'Solomon Islands', dialCode: '+677' }, - { code: 'SC', name: 'Seychelles', dialCode: '+248' }, - { code: 'SD', name: 'Sudan', dialCode: '+249' }, - { code: 'SE', name: 'Sweden', dialCode: '+46' }, - { code: 'SG', name: 'Singapore', dialCode: '+65' }, - { code: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha', dialCode: '+290' }, - { code: 'SI', name: 'Slovenia', dialCode: '+386' }, - { code: 'SJ', name: 'Svalbard and Jan Mayen', dialCode: '+47' }, - { code: 'SK', name: 'Slovakia', dialCode: '+421' }, - { code: 'SL', name: 'Sierra Leone', dialCode: '+232' }, - { code: 'SM', name: 'San Marino', dialCode: '+378' }, - { code: 'SN', name: 'Senegal', dialCode: '+221' }, - { code: 'SO', name: 'Somalia', dialCode: '+252' }, - { code: 'SR', name: 'Suriname', dialCode: '+597' }, - { code: 'SS', name: 'South Sudan', dialCode: '+211' }, - { code: 'ST', name: 'Sao Tome and Principe', dialCode: '+239' }, - { code: 'SV', name: 'El Salvador', dialCode: '+503' }, - { code: 'SX', name: 'Sint Maarten (Dutch part)', dialCode: '+1721' }, - { code: 'SY', name: 'Syrian Arab Republic', dialCode: '+963' }, - { code: 'SZ', name: 'Eswatini', dialCode: '+268' }, - { code: 'TC', name: 'Turks and Caicos Islands', dialCode: '+1649' }, - { code: 'TD', name: 'Chad', dialCode: '+235' }, - { code: 'TF', name: 'French Southern Territories', dialCode: '+262' }, - { code: 'TG', name: 'Togo', dialCode: '+228' }, - { code: 'TH', name: 'Thailand', dialCode: '+66' }, - { code: 'TJ', name: 'Tajikistan', dialCode: '+992' }, - { code: 'TK', name: 'Tokelau', dialCode: '+690' }, - { code: 'TL', name: 'Timor-Leste', dialCode: '+670' }, - { code: 'TM', name: 'Turkmenistan', dialCode: '+993' }, - { code: 'TN', name: 'Tunisia', dialCode: '+216' }, - { code: 'TO', name: 'Tonga', dialCode: '+676' }, - { code: 'TR', name: 'Turkey', dialCode: '+90' }, - { code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1868' }, - { code: 'TV', name: 'Tuvalu', dialCode: '+688' }, - { code: 'TW', name: 'Taiwan, Province of China', dialCode: '+886' }, - { code: 'TZ', name: 'Tanzania, United Republic of', dialCode: '+255' }, - { code: 'UA', name: 'Ukraine', dialCode: '+380' }, - { code: 'UG', name: 'Uganda', dialCode: '+256' }, - { code: 'UM', name: 'United States Minor Outlying Islands', dialCode: '+1' }, - { code: 'US', name: 'United States', dialCode: '+1' }, - { code: 'UY', name: 'Uruguay', dialCode: '+598' }, - { code: 'UZ', name: 'Uzbekistan', dialCode: '+998' }, - { code: 'VA', name: 'Holy See (Vatican City State)', dialCode: '+379' }, - { code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784' }, - { code: 'VE', name: 'Venezuela', dialCode: '+58' }, - { code: 'VG', name: 'Virgin Islands, British', dialCode: '+1284' }, - { code: 'VI', name: 'Virgin Islands, U.S.', dialCode: '+1340' }, - { code: 'VN', name: 'Viet Nam', dialCode: '+84' }, - { code: 'VU', name: 'Vanuatu', dialCode: '+678' }, - { code: 'WF', name: 'Wallis and Futuna', dialCode: '+681' }, - { code: 'WS', name: 'Samoa', dialCode: '+685' }, - { code: 'YE', name: 'Yemen', dialCode: '+967' }, - { code: 'YT', name: 'Mayotte', dialCode: '+262' }, - { code: 'ZA', name: 'South Africa', dialCode: '+27' }, - { code: 'ZM', name: 'Zambia', dialCode: '+260' }, - { code: 'ZW', name: 'Zimbabwe', dialCode: '+263' }, -]; diff --git a/frontend/src/app/consts/currencies.ts b/frontend/src/app/consts/currencies.ts deleted file mode 100644 index 174c5bfef..000000000 --- a/frontend/src/app/consts/currencies.ts +++ /dev/null @@ -1,87 +0,0 @@ -export interface Money { - code: string; - name: string; - symbol: string; - flag?: string; -} - -export interface MoneyValue { - amount: number | string; - currency: string; -} - -export const CURRENCIES: Money[] = [ - { code: 'USD', name: 'US Dollar', symbol: '$', flag: '🇺🇸' }, - { code: 'EUR', name: 'Euro', symbol: '€', flag: '🇪🇺' }, - { code: 'GBP', name: 'British Pound', symbol: '£', flag: '🇬🇧' }, - { code: 'JPY', name: 'Japanese Yen', symbol: '¥', flag: '🇯🇵' }, - { code: 'CHF', name: 'Swiss Franc', symbol: 'CHF', flag: '🇨🇭' }, - { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$', flag: '🇨🇦' }, - { code: 'AUD', name: 'Australian Dollar', symbol: 'A$', flag: '🇦🇺' }, - { code: 'CNY', name: 'Chinese Yuan', symbol: '¥', flag: '🇨🇳' }, - { code: 'INR', name: 'Indian Rupee', symbol: '₹', flag: '🇮🇳' }, - { code: 'KRW', name: 'South Korean Won', symbol: '₩', flag: '🇰🇷' }, - { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$', flag: '🇸🇬' }, - { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$', flag: '🇭🇰' }, - { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr', flag: '🇳🇴' }, - { code: 'SEK', name: 'Swedish Krona', symbol: 'kr', flag: '🇸🇪' }, - { code: 'DKK', name: 'Danish Krone', symbol: 'kr', flag: '🇩🇰' }, - { code: 'PLN', name: 'Polish Zloty', symbol: 'zł', flag: '🇵🇱' }, - { code: 'CZK', name: 'Czech Koruna', symbol: 'Kč', flag: '🇨🇿' }, - { code: 'HUF', name: 'Hungarian Forint', symbol: 'Ft', flag: '🇭🇺' }, - { code: 'RUB', name: 'Russian Ruble', symbol: '₽', flag: '🇷🇺' }, - { code: 'BRL', name: 'Brazilian Real', symbol: 'R$', flag: '🇧🇷' }, - { code: 'MXN', name: 'Mexican Peso', symbol: '$', flag: '🇲🇽' }, - { code: 'ZAR', name: 'South African Rand', symbol: 'R', flag: '🇿🇦' }, - { code: 'TRY', name: 'Turkish Lira', symbol: '₺', flag: '🇹🇷' }, - { code: 'AED', name: 'UAE Dirham', symbol: 'د.إ', flag: '🇦🇪' }, - { code: 'SAR', name: 'Saudi Riyal', symbol: '﷼', flag: '🇸🇦' }, - { code: 'ILS', name: 'Israeli Shekel', symbol: '₪', flag: '🇮🇱' }, - { code: 'EGP', name: 'Egyptian Pound', symbol: '£', flag: '🇪🇬' }, - { code: 'THB', name: 'Thai Baht', symbol: '฿', flag: '🇹🇭' }, - { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM', flag: '🇲🇾' }, - { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp', flag: '🇮🇩' }, - { code: 'PHP', name: 'Philippine Peso', symbol: '₱', flag: '🇵🇭' }, - { code: 'VND', name: 'Vietnamese Dong', symbol: '₫', flag: '🇻🇳' }, - { code: 'UAH', name: 'Ukrainian Hryvnia', symbol: '₴', flag: '🇺🇦' }, -]; - -export function getCurrencyByCode(code: string): Money | undefined { - return CURRENCIES.find((currency) => currency.code === code); -} - -export function getCurrencySymbol(code: string): string { - const currency = getCurrencyByCode(code); - return currency ? currency.symbol : ''; -} - -export const ZERO_DECIMAL_CURRENCIES = new Set([ - 'BIF', - 'CLP', - 'DJF', - 'GNF', - 'JPY', - 'KMF', - 'KRW', - 'MGA', - 'PYG', - 'RWF', - 'UGX', - 'VND', - 'VUV', - 'XAF', - 'XOF', - 'XPF', -]); - -export function isZeroDecimalCurrency(code: string): boolean { - return ZERO_DECIMAL_CURRENCIES.has(code); -} - -export function getCurrencyDecimalPlaces(code: string): number { - return isZeroDecimalCurrency(code) ? 0 : 2; -} - -export function getCurrencyMinorUnitFactor(code: string): number { - return isZeroDecimalCurrency(code) ? 1 : 100; -} diff --git a/frontend/src/app/consts/databases.ts b/frontend/src/app/consts/databases.ts deleted file mode 100644 index 9a1772132..000000000 --- a/frontend/src/app/consts/databases.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const supportedOrderedDatabases = [ - 'mysql', - 'postgres', - 'mongodb', - 'dynamodb', - 'cassandra', - 'oracledb', - 'mssql', - 'redis', - 'elasticsearch', - 'clickhouse', -]; - -export const supportedDatabasesTitles = { - mysql: 'MySQL (MariaDB)', - postgres: 'PostgreSQL', - mongodb: 'MongoDB', - dynamodb: 'DynamoDB', - cassandra: 'Cassandra (ScyllaDB)', - oracledb: 'OracleDB', - mssql: 'SQL Server', - redis: 'Redis', - elasticsearch: 'Elasticsearch (OpenSearch)', - clickhouse: 'ClickHouse', -}; diff --git a/frontend/src/app/consts/filter-types.ts b/frontend/src/app/consts/filter-types.ts deleted file mode 100644 index 8c131e2b8..000000000 --- a/frontend/src/app/consts/filter-types.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { BooleanFilterComponent } from 'src/app/components/ui-components/filter-fields/boolean/boolean.component'; -import { LongTextFilterComponent } from 'src/app/components/ui-components/filter-fields/long-text/long-text.component'; -import { NumberFilterComponent } from 'src/app/components/ui-components/filter-fields/number/number.component'; -import { PointFilterComponent } from 'src/app/components/ui-components/filter-fields/point/point.component'; -import { TextFilterComponent } from 'src/app/components/ui-components/filter-fields/text/text.component'; -import { BinaryFilterComponent } from '../components/ui-components/filter-fields/binary/binary.component'; -import { CountryFilterComponent } from '../components/ui-components/filter-fields/country/country.component'; -import { DateFilterComponent } from '../components/ui-components/filter-fields/date/date.component'; -import { DateTimeFilterComponent } from '../components/ui-components/filter-fields/date-time/date-time.component'; -import { EmailFilterComponent } from '../components/ui-components/filter-fields/email/email.component'; -import { FileFilterComponent } from '../components/ui-components/filter-fields/file/file.component'; -import { ForeignKeyFilterComponent } from '../components/ui-components/filter-fields/foreign-key/foreign-key.component'; -import { IdFilterComponent } from '../components/ui-components/filter-fields/id/id.component'; -import { JsonEditorFilterComponent } from '../components/ui-components/filter-fields/json-editor/json-editor.component'; -import { PasswordFilterComponent } from '../components/ui-components/filter-fields/password/password.component'; -import { PhoneFilterComponent } from '../components/ui-components/filter-fields/phone/phone.component'; -import { SelectFilterComponent } from '../components/ui-components/filter-fields/select/select.component'; -import { StaticTextFilterComponent } from '../components/ui-components/filter-fields/static-text/static-text.component'; -import { TimeFilterComponent } from '../components/ui-components/filter-fields/time/time.component'; -import { TimeIntervalFilterComponent } from '../components/ui-components/filter-fields/time-interval/time-interval.component'; -import { TimezoneFilterComponent } from '../components/ui-components/filter-fields/timezone/timezone.component'; - -export const UIwidgets = { - Default: '', - Boolean: BooleanFilterComponent, - Country: CountryFilterComponent, - Date: DateFilterComponent, - DateTime: DateTimeFilterComponent, - Binary: BinaryFilterComponent, - File: FileFilterComponent, - Foreign_key: ForeignKeyFilterComponent, - JSON: JsonEditorFilterComponent, - Number: NumberFilterComponent, - Password: PasswordFilterComponent, - Readonly: StaticTextFilterComponent, - Select: SelectFilterComponent, - String: TextFilterComponent, - Textarea: LongTextFilterComponent, - Time: TimeFilterComponent, - TimeInterval: TimeIntervalFilterComponent, - Timezone: TimezoneFilterComponent, - Point: PointFilterComponent, - ID: IdFilterComponent, - Phone: PhoneFilterComponent, - Email: EmailFilterComponent, -}; - -export const filterTypes = { - postgres: { - // numbers (number) - real: NumberFilterComponent, - 'double precision': NumberFilterComponent, - smallint: NumberFilterComponent, - integer: NumberFilterComponent, - bigint: NumberFilterComponent, - numeric: NumberFilterComponent, - - //boolean (checkbox) - boolean: BooleanFilterComponent, - - //datetime (datepicker) - 'timestamp without time zone': DateTimeFilterComponent, - 'timestamp with time zone': DateTimeFilterComponent, - date: DateFilterComponent, - abstime: DateTimeFilterComponent, - realtime: DateTimeFilterComponent, - interval: TimeIntervalFilterComponent, // number + select(seconds, days, weeks, years) - - // short text (text) - 'character varying': TextFilterComponent, - macaddr: TextFilterComponent, //to do regexp - macaddr8: TextFilterComponent, //to do regexp - cidr: TextFilterComponent, //to do regexp - inet: TextFilterComponent, //to do regexp - uuid: TextFilterComponent, //to do regexp - - //long text (textarea) - text: LongTextFilterComponent, - xml: LongTextFilterComponent, - - //select (select) - enum: SelectFilterComponent, - - // json-editor - json: JsonEditorFilterComponent, //json-editor - jsonb: JsonEditorFilterComponent, //json-editor - - //binary - bytea: BinaryFilterComponent, - - //etc - money: TextFilterComponent, - - //mess (math) - point: PointFilterComponent, - line: TextFilterComponent, - circle: TextFilterComponent, - path: TextFilterComponent, - box: TextFilterComponent, - lseg: TextFilterComponent, - - 'foreign key': ForeignKeyFilterComponent, - }, - - mysql: { - // numbers (number) - tinyint: NumberFilterComponent, - smallint: NumberFilterComponent, - mediumint: NumberFilterComponent, - int: NumberFilterComponent, - bigint: NumberFilterComponent, - decimal: NumberFilterComponent, - float: NumberFilterComponent, - double: NumberFilterComponent, - year: NumberFilterComponent, - - //boolean (radiogroup) - boolean: BooleanFilterComponent, - - //datetime (datepicker) - date: DateFilterComponent, - time: TimeFilterComponent, - datetime: DateTimeFilterComponent, - timestamp: DateTimeFilterComponent, - - // short text (text) - char: TextFilterComponent, - varchar: TextFilterComponent, - - //long text (textarea) - text: LongTextFilterComponent, - tinytext: LongTextFilterComponent, - mediumtext: LongTextFilterComponent, - longtext: LongTextFilterComponent, - - json: JsonEditorFilterComponent, //json-editor - - //select (select) - enum: SelectFilterComponent, - - //binary - binary: BinaryFilterComponent, - varbinary: BinaryFilterComponent, - blob: BinaryFilterComponent, - tinyblob: BinaryFilterComponent, - mediumblob: BinaryFilterComponent, - longblob: BinaryFilterComponent, - - //etc - set: TextFilterComponent, //(text) - - 'foreign key': ForeignKeyFilterComponent, - }, - - oracledb: { - // numbers (number) - NUMBER: NumberFilterComponent, - FLOAT: NumberFilterComponent, - BINARY_FLOAT: NumberFilterComponent, - BINARY_DOUBLE: NumberFilterComponent, - 'INTERVAL YEAR': NumberFilterComponent, - 'INTERVAL DAY': NumberFilterComponent, - - //datetime (datepicker) - DATE: DateFilterComponent, - TIMESTAMP: DateTimeFilterComponent, - - // short text (text) - CHAR: TextFilterComponent, - NCHAR: TextFilterComponent, - CLOB: TextFilterComponent, - NCLOB: TextFilterComponent, - VARCHAR2: TextFilterComponent, - VARCHAR: TextFilterComponent, - NVARCHAR2: TextFilterComponent, - - //binary - BLOB: BinaryFilterComponent, - BFILE: BinaryFilterComponent, - RAW: BinaryFilterComponent, - 'LONG RAW': BinaryFilterComponent, - LONG: BinaryFilterComponent, - - 'foreign key': ForeignKeyFilterComponent, - }, - - mssql: { - // numbers (number) - bigint: NumberFilterComponent, - int: NumberFilterComponent, - smallint: NumberFilterComponent, - tinyint: NumberFilterComponent, - decimal: NumberFilterComponent, - bitdecimal: NumberFilterComponent, - numeric: NumberFilterComponent, - real: NumberFilterComponent, - - // short text (text) - uniqueidentifier: IdFilterComponent, - char: TextFilterComponent, - varchar: TextFilterComponent, - - //long text (textarea) - text: LongTextFilterComponent, - nchar: LongTextFilterComponent, - nvarchar: LongTextFilterComponent, - ntext: LongTextFilterComponent, - - //datetime (datepicker) - date: DateFilterComponent, - datetime: DateTimeFilterComponent, - smalldatetime: DateTimeFilterComponent, - timestamp: DateTimeFilterComponent, - - //binary - binary: BinaryFilterComponent, - varbinary: BinaryFilterComponent, - image: BinaryFilterComponent, - - // etc - money: TextFilterComponent, - smallmoney: TextFilterComponent, - - 'foreign key': ForeignKeyFilterComponent, - }, - mongodb: { - // numbers (number) - number: NumberFilterComponent, - double: NumberFilterComponent, - int32: NumberFilterComponent, - long: NumberFilterComponent, - decimal128: NumberFilterComponent, - - //boolean (radiogroup) - boolean: BooleanFilterComponent, - - //datetime (datepicker) - date: DateFilterComponent, - timestamp: DateTimeFilterComponent, - - // short text (text) - string: TextFilterComponent, - regexp: TextFilterComponent, - objectid: TextFilterComponent, - - //binary - binary: BinaryFilterComponent, - - //json - //json - object: JsonEditorFilterComponent, - array: JsonEditorFilterComponent, - - 'foreign key': ForeignKeyFilterComponent, - }, - dynamodb: { - string: TextFilterComponent, - number: NumberFilterComponent, - boolean: BooleanFilterComponent, - null: StaticTextFilterComponent, - array: JsonEditorFilterComponent, - json: JsonEditorFilterComponent, - binary: BinaryFilterComponent, - }, - cassandra: { - int: NumberFilterComponent, - bigint: NumberFilterComponent, - varint: NumberFilterComponent, - decimal: NumberFilterComponent, - float: NumberFilterComponent, - double: NumberFilterComponent, - - boolean: BooleanFilterComponent, - - timeuuid: IdFilterComponent, - - timestamp: DateTimeFilterComponent, - date: DateFilterComponent, - time: TimeFilterComponent, - - uuid: TextFilterComponent, - varchar: TextFilterComponent, - inet: TextFilterComponent, - ascii: TextFilterComponent, - text: TextFilterComponent, - - //binary - blob: BinaryFilterComponent, - - list: JsonEditorFilterComponent, - map: JsonEditorFilterComponent, - set: JsonEditorFilterComponent, - }, -}; diff --git a/frontend/src/app/consts/google-IP-addresses.ts b/frontend/src/app/consts/google-IP-addresses.ts deleted file mode 100644 index d11fffb09..000000000 --- a/frontend/src/app/consts/google-IP-addresses.ts +++ /dev/null @@ -1,530 +0,0 @@ -export default [ - '34.80.0.0/15', - '34.137.0.0/16', - '35.185.128.0/19', - '35.185.160.0/20', - '35.187.144.0/20', - '35.189.160.0/19', - '35.194.128.0/17', - '35.201.128.0/17', - '35.206.192.0/18', - '35.220.32.0/21', - '35.221.128.0/17', - '35.229.128.0/17', - '35.234.0.0/18', - '35.235.16.0/20', - '35.236.128.0/18', - '35.242.32.0/21', - '104.155.192.0/19', - '104.155.224.0/20', - '104.199.128.0/18', - '104.199.192.0/19', - '104.199.224.0/20', - '104.199.242.0/23', - '104.199.244.0/22', - '104.199.248.0/21', - '107.167.176.0/20', - '130.211.240.0/20', - '34.92.0.0/16', - '34.96.128.0/17', - '34.104.88.0/21', - '34.124.24.0/21', - '34.150.0.0/17', - '35.215.128.0/18', - '35.220.27.0/24', - '35.220.128.0/17', - '35.241.64.0/18', - '35.242.27.0/24', - '35.243.8.0/21', - '34.84.0.0/16', - '34.85.0.0/17', - '34.104.62.0/23', - '34.104.128.0/17', - '34.127.190.0/23', - '34.146.0.0/16', - '34.157.64.0/20', - '34.157.164.0/22', - '34.157.192.0/20', - '35.187.192.0/19', - '35.189.128.0/19', - '35.190.224.0/20', - '35.194.96.0/19', - '35.200.0.0/17', - '35.213.0.0/17', - '35.220.56.0/22', - '35.221.64.0/18', - '35.230.240.0/20', - '35.242.56.0/22', - '35.243.64.0/18', - '104.198.80.0/20', - '104.198.112.0/20', - '34.97.0.0/16', - '34.104.49.0/24', - '34.127.177.0/24', - '35.217.128.0/17', - '35.220.45.0/24', - '35.242.45.0/24', - '35.243.56.0/21', - '34.64.32.0/19', - '34.64.64.0/22', - '34.64.68.0/22', - '34.64.72.0/21', - '34.64.80.0/20', - '34.64.96.0/19', - '34.64.128.0/22', - '34.64.132.0/22', - '34.64.136.0/21', - '34.64.144.0/20', - '34.64.160.0/19', - '34.64.192.0/18', - '35.216.0.0/17', - '34.93.0.0/16', - '34.100.128.0/17', - '34.104.108.0/23', - '34.124.44.0/23', - '34.157.87.0/24', - '34.157.215.0/24', - '35.200.128.0/17', - '35.201.41.0/24', - '35.207.192.0/18', - '35.220.42.0/24', - '35.234.208.0/20', - '35.242.42.0/24', - '35.244.0.0/18', - '34.104.120.0/23', - '34.124.56.0/23', - '34.126.208.0/20', - '34.131.0.0/16', - '34.21.128.0/17', - '34.87.0.0/17', - '34.87.128.0/18', - '34.104.58.0/23', - '34.104.106.0/23', - '34.124.42.0/23', - '34.124.128.0/17', - '34.126.64.0/18', - '34.126.128.0/18', - '34.142.128.0/17', - '34.143.128.0/17', - '34.157.82.0/23', - '34.157.88.0/23', - '34.157.210.0/23', - '35.185.176.0/20', - '35.186.144.0/20', - '35.187.224.0/19', - '35.197.128.0/19', - '35.198.192.0/18', - '35.213.128.0/18', - '35.220.24.0/23', - '35.234.192.0/20', - '35.240.128.0/17', - '35.242.24.0/23', - '35.247.128.0/18', - '34.101.18.0/24', - '34.101.20.0/22', - '34.101.24.0/22', - '34.101.32.0/19', - '34.101.64.0/18', - '34.101.128.0/17', - '34.128.64.0/18', - '35.219.0.0/17', - '34.87.192.0/18', - '34.104.104.0/23', - '34.116.64.0/18', - '34.124.40.0/23', - '34.151.64.0/18', - '34.151.128.0/18', - '35.189.0.0/18', - '35.197.160.0/19', - '35.201.0.0/19', - '35.213.192.0/18', - '35.220.41.0/24', - '35.234.224.0/20', - '35.242.41.0/24', - '35.244.64.0/18', - '34.104.122.0/23', - '34.124.58.0/23', - '34.126.192.0/20', - '34.129.0.0/16', - '34.104.116.0/22', - '34.116.128.0/17', - '34.118.0.0/17', - '34.124.52.0/22', - '34.88.0.0/16', - '34.104.96.0/21', - '34.124.32.0/21', - '35.203.232.0/21', - '35.217.0.0/18', - '35.220.26.0/24', - '35.228.0.0/16', - '35.242.26.0/24', - '34.157.44.0/23', - '34.157.172.0/23', - '34.164.0.0/16', - '34.175.0.0/16', - '8.34.208.0/23', - '8.34.211.0/24', - '8.34.220.0/22', - '23.251.128.0/20', - '34.76.0.0/14', - '34.118.254.0/23', - '34.140.0.0/16', - '35.187.0.0/17', - '35.187.160.0/19', - '35.189.192.0/18', - '35.190.192.0/19', - '35.195.0.0/16', - '35.205.0.0/16', - '35.206.128.0/18', - '35.210.0.0/16', - '35.220.96.0/19', - '35.233.0.0/17', - '35.240.0.0/17', - '35.241.128.0/17', - '35.242.64.0/19', - '104.155.0.0/17', - '104.199.0.0/18', - '104.199.66.0/23', - '104.199.68.0/22', - '104.199.72.0/21', - '104.199.80.0/20', - '104.199.96.0/20', - '130.211.48.0/20', - '130.211.64.0/19', - '130.211.96.0/20', - '146.148.2.0/23', - '146.148.4.0/22', - '146.148.8.0/21', - '146.148.16.0/20', - '146.148.112.0/20', - '192.158.28.0/22', - '34.89.0.0/17', - '34.105.128.0/17', - '34.127.186.0/23', - '34.142.0.0/17', - '34.147.128.0/17', - '34.157.36.0/22', - '34.157.40.0/22', - '34.157.168.0/22', - '35.189.64.0/18', - '35.197.192.0/18', - '35.203.210.0/23', - '35.203.212.0/22', - '35.203.216.0/22', - '35.214.0.0/17', - '35.220.20.0/22', - '35.230.128.0/19', - '35.234.128.0/19', - '35.235.48.0/20', - '35.242.20.0/22', - '35.242.128.0/18', - '35.246.0.0/17', - '34.89.128.0/17', - '34.104.112.0/23', - '34.107.0.0/17', - '34.118.244.0/22', - '34.124.48.0/23', - '34.141.0.0/17', - '34.157.48.0/20', - '34.157.176.0/20', - '34.159.0.0/16', - '35.198.64.0/18', - '35.198.128.0/18', - '35.207.64.0/18', - '35.207.128.0/18', - '35.220.18.0/23', - '35.234.64.0/18', - '35.235.32.0/20', - '35.242.18.0/23', - '35.242.192.0/18', - '35.246.128.0/17', - '34.90.0.0/15', - '34.104.126.0/23', - '34.124.62.0/23', - '34.141.128.0/17', - '34.147.0.0/17', - '34.157.80.0/23', - '34.157.92.0/22', - '34.157.208.0/23', - '34.157.220.0/22', - '35.204.0.0/16', - '35.214.128.0/17', - '35.220.16.0/23', - '35.234.160.0/20', - '35.242.16.0/23', - '34.65.0.0/16', - '34.104.110.0/23', - '34.124.46.0/23', - '35.216.128.0/17', - '35.220.44.0/24', - '35.235.216.0/21', - '35.242.44.0/24', - '34.154.0.0/16', - '34.157.8.0/23', - '34.157.136.0/23', - '35.219.224.0/19', - '34.155.0.0/16', - '34.157.12.0/22', - '34.157.140.0/22', - '34.163.0.0/16', - '34.95.64.0/18', - '34.96.64.0/18', - '34.98.64.0/18', - '34.102.128.0/17', - '34.104.27.0/24', - '34.107.128.0/17', - '34.110.128.0/17', - '34.111.0.0/16', - '34.116.0.0/21', - '34.117.0.0/16', - '34.120.0.0/16', - '34.128.128.0/18', - '34.144.192.0/18', - '34.149.0.0/16', - '34.160.0.0/16', - '35.186.192.0/18', - '35.190.0.0/18', - '35.190.64.0/19', - '35.190.112.0/20', - '35.201.64.0/18', - '35.227.192.0/18', - '35.241.0.0/18', - '35.244.128.0/17', - '107.178.240.0/20', - '130.211.4.0/22', - '130.211.8.0/21', - '130.211.16.0/20', - '130.211.32.0/20', - '34.157.90.0/23', - '34.157.216.0/23', - '34.165.0.0/16', - '34.19.128.0/17', - '34.20.0.0/17', - '34.95.0.0/18', - '34.104.76.0/22', - '34.118.128.0/18', - '34.124.12.0/22', - '34.152.0.0/18', - '35.203.0.0/17', - '35.215.0.0/18', - '35.220.43.0/24', - '35.234.240.0/20', - '35.242.43.0/24', - '34.104.114.0/23', - '34.124.50.0/23', - '34.124.112.0/20', - '34.130.0.0/16', - '34.95.128.0/17', - '34.104.80.0/21', - '34.124.16.0/21', - '34.151.0.0/18', - '34.151.192.0/18', - '35.198.0.0/18', - '35.199.64.0/18', - '35.215.192.0/18', - '35.220.40.0/24', - '35.235.0.0/20', - '35.242.40.0/24', - '35.247.192.0/18', - '34.104.50.0/23', - '34.127.178.0/23', - '34.176.0.0/16', - '8.34.210.0/24', - '8.34.212.0/22', - '8.34.216.0/22', - '8.35.192.0/21', - '23.236.48.0/20', - '23.251.144.0/20', - '34.16.0.0/17', - '34.27.0.0/16', - '34.28.0.0/15', - '34.30.0.0/16', - '34.66.0.0/15', - '34.68.0.0/14', - '34.72.0.0/16', - '34.118.200.0/21', - '34.121.0.0/16', - '34.122.0.0/15', - '34.132.0.0/14', - '34.136.0.0/16', - '34.157.84.0/23', - '34.157.96.0/20', - '34.157.212.0/23', - '34.157.224.0/20', - '34.170.0.0/15', - '34.172.0.0/15', - '35.184.0.0/16', - '35.188.0.0/17', - '35.188.128.0/18', - '35.188.192.0/19', - '35.192.0.0/15', - '35.194.0.0/18', - '35.202.0.0/16', - '35.206.64.0/18', - '35.208.0.0/15', - '35.220.64.0/19', - '35.222.0.0/15', - '35.224.0.0/15', - '35.226.0.0/16', - '35.232.0.0/16', - '35.238.0.0/15', - '35.242.96.0/19', - '104.154.16.0/20', - '104.154.32.0/19', - '104.154.64.0/19', - '104.154.96.0/20', - '104.154.113.0/24', - '104.154.114.0/23', - '104.154.116.0/22', - '104.154.120.0/23', - '104.154.128.0/17', - '104.155.128.0/18', - '104.197.0.0/16', - '104.198.16.0/20', - '104.198.32.0/19', - '104.198.64.0/20', - '104.198.128.0/17', - '107.178.208.0/20', - '108.59.80.0/21', - '130.211.112.0/20', - '130.211.128.0/18', - '130.211.192.0/19', - '130.211.224.0/20', - '146.148.32.0/19', - '146.148.64.0/19', - '146.148.96.0/20', - '162.222.176.0/21', - '173.255.112.0/21', - '199.192.115.0/24', - '199.223.232.0/22', - '199.223.236.0/24', - '34.22.0.0/19', - '35.186.0.0/17', - '35.186.128.0/20', - '35.206.32.0/19', - '35.220.46.0/24', - '35.242.46.0/24', - '107.167.160.0/20', - '108.59.88.0/21', - '173.255.120.0/21', - '34.23.0.0/16', - '34.24.0.0/15', - '34.26.0.0/16', - '34.73.0.0/16', - '34.74.0.0/15', - '34.98.128.0/21', - '34.118.250.0/23', - '34.138.0.0/15', - '34.148.0.0/16', - '35.185.0.0/17', - '35.190.128.0/18', - '35.196.0.0/16', - '35.207.0.0/18', - '35.211.0.0/16', - '35.220.0.0/20', - '35.227.0.0/17', - '35.229.16.0/20', - '35.229.32.0/19', - '35.229.64.0/18', - '35.231.0.0/16', - '35.237.0.0/16', - '35.242.0.0/20', - '35.243.128.0/17', - '104.196.0.0/18', - '104.196.65.0/24', - '104.196.66.0/23', - '104.196.68.0/22', - '104.196.96.0/19', - '104.196.128.0/18', - '104.196.192.0/19', - '162.216.148.0/22', - '34.21.0.0/17', - '34.85.128.0/17', - '34.86.0.0/16', - '34.104.60.0/23', - '34.104.124.0/23', - '34.118.252.0/23', - '34.124.60.0/23', - '34.127.188.0/23', - '34.145.128.0/17', - '34.150.128.0/17', - '34.157.0.0/21', - '34.157.16.0/20', - '34.157.128.0/21', - '34.157.144.0/20', - '35.186.160.0/19', - '35.188.224.0/19', - '35.194.64.0/19', - '35.199.0.0/18', - '35.212.0.0/17', - '35.220.60.0/22', - '35.221.0.0/18', - '35.230.160.0/19', - '35.234.176.0/20', - '35.236.192.0/18', - '35.242.60.0/22', - '35.243.40.0/21', - '35.245.0.0/16', - '34.157.32.0/22', - '34.157.160.0/22', - '34.162.0.0/16', - '34.104.56.0/23', - '34.127.184.0/23', - '34.161.0.0/16', - '35.206.10.0/23', - '34.157.46.0/23', - '34.157.174.0/23', - '34.174.0.0/16', - '34.19.0.0/17', - '34.82.0.0/15', - '34.105.0.0/17', - '34.118.192.0/21', - '34.127.0.0/17', - '34.145.0.0/17', - '34.157.112.0/21', - '34.157.240.0/21', - '34.168.0.0/15', - '35.185.192.0/18', - '35.197.0.0/17', - '35.199.144.0/20', - '35.199.160.0/19', - '35.203.128.0/18', - '35.212.128.0/17', - '35.220.48.0/21', - '35.227.128.0/18', - '35.230.0.0/17', - '35.233.128.0/17', - '35.242.48.0/21', - '35.243.32.0/21', - '35.247.0.0/17', - '104.196.224.0/19', - '104.198.0.0/20', - '104.198.96.0/20', - '104.199.112.0/20', - '34.20.128.0/17', - '34.94.0.0/16', - '34.102.0.0/17', - '34.104.64.0/21', - '34.108.0.0/16', - '34.118.248.0/23', - '34.124.0.0/21', - '35.215.64.0/18', - '35.220.47.0/24', - '35.235.64.0/18', - '35.236.0.0/17', - '35.242.47.0/24', - '35.243.0.0/21', - '34.22.32.0/19', - '34.104.52.0/24', - '34.106.0.0/16', - '34.127.180.0/24', - '35.217.64.0/18', - '35.220.31.0/24', - '35.242.31.0/24', - '34.16.128.0/17', - '34.104.72.0/22', - '34.118.240.0/22', - '34.124.8.0/22', - '34.125.0.0/16', - '35.219.128.0/18', -]; diff --git a/frontend/src/app/consts/languages.ts b/frontend/src/app/consts/languages.ts deleted file mode 100644 index 7db4c4387..000000000 --- a/frontend/src/app/consts/languages.ts +++ /dev/null @@ -1,201 +0,0 @@ -export interface Language { - code: string; - name: string; - nativeName?: string; - flagCountryCode?: string; // ISO 3166-1 alpha-2 country code for flag emoji -} - -export function getLanguageFlag(language: Language): string { - if (!language.flagCountryCode || language.flagCountryCode.length !== 2) return ''; - const codePoints = language.flagCountryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -} - -export const LANGUAGES: Language[] = [ - { code: 'aa', name: 'Afar', nativeName: 'Afaraf', flagCountryCode: 'ET' }, - { code: 'ab', name: 'Abkhazian', nativeName: 'аҧсуа бызшәа', flagCountryCode: 'GE' }, - { code: 'af', name: 'Afrikaans', nativeName: 'Afrikaans', flagCountryCode: 'ZA' }, - { code: 'ak', name: 'Akan', nativeName: 'Akan', flagCountryCode: 'GH' }, - { code: 'am', name: 'Amharic', nativeName: 'አማርኛ', flagCountryCode: 'ET' }, - { code: 'an', name: 'Aragonese', nativeName: 'aragonés', flagCountryCode: 'ES' }, - { code: 'ar', name: 'Arabic', nativeName: 'العربية', flagCountryCode: 'SA' }, - { code: 'as', name: 'Assamese', nativeName: 'অসমীয়া', flagCountryCode: 'IN' }, - { code: 'av', name: 'Avaric', nativeName: 'авар мацӀ', flagCountryCode: 'RU' }, - { code: 'ay', name: 'Aymara', nativeName: 'aymar aru', flagCountryCode: 'BO' }, - { code: 'az', name: 'Azerbaijani', nativeName: 'azərbaycan dili', flagCountryCode: 'AZ' }, - { code: 'ba', name: 'Bashkir', nativeName: 'башҡорт теле', flagCountryCode: 'RU' }, - { code: 'be', name: 'Belarusian', nativeName: 'беларуская мова', flagCountryCode: 'BY' }, - { code: 'bg', name: 'Bulgarian', nativeName: 'български език', flagCountryCode: 'BG' }, - { code: 'bh', name: 'Bihari languages', nativeName: 'भोजपुरी', flagCountryCode: 'IN' }, - { code: 'bi', name: 'Bislama', nativeName: 'Bislama', flagCountryCode: 'VU' }, - { code: 'bm', name: 'Bambara', nativeName: 'bamanankan', flagCountryCode: 'ML' }, - { code: 'bn', name: 'Bengali', nativeName: 'বাংলা', flagCountryCode: 'BD' }, - { code: 'bo', name: 'Tibetan', nativeName: 'བོད་ཡིག', flagCountryCode: 'CN' }, - { code: 'br', name: 'Breton', nativeName: 'brezhoneg', flagCountryCode: 'FR' }, - { code: 'bs', name: 'Bosnian', nativeName: 'bosanski jezik', flagCountryCode: 'BA' }, - { code: 'ca', name: 'Catalan', nativeName: 'català', flagCountryCode: 'ES' }, - { code: 'ce', name: 'Chechen', nativeName: 'нохчийн мott', flagCountryCode: 'RU' }, - { code: 'ch', name: 'Chamorro', nativeName: 'Chamoru', flagCountryCode: 'GU' }, - { code: 'co', name: 'Corsican', nativeName: 'corsu', flagCountryCode: 'FR' }, - { code: 'cr', name: 'Cree', nativeName: 'ᓀᐦᐃᔭᐍᐏᐣ', flagCountryCode: 'CA' }, - { code: 'cs', name: 'Czech', nativeName: 'čeština', flagCountryCode: 'CZ' }, - { code: 'cu', name: 'Church Slavonic', nativeName: 'ѩзыкъ словѣньскъ', flagCountryCode: 'BG' }, - { code: 'cv', name: 'Chuvash', nativeName: 'чӑваш чӗлхи', flagCountryCode: 'RU' }, - { code: 'cy', name: 'Welsh', nativeName: 'Cymraeg', flagCountryCode: 'GB' }, - { code: 'da', name: 'Danish', nativeName: 'dansk', flagCountryCode: 'DK' }, - { code: 'de', name: 'German', nativeName: 'Deutsch', flagCountryCode: 'DE' }, - { code: 'dv', name: 'Divehi', nativeName: 'ދިވެހި', flagCountryCode: 'MV' }, - { code: 'dz', name: 'Dzongkha', nativeName: 'རྫོང་ཁ', flagCountryCode: 'BT' }, - { code: 'ee', name: 'Ewe', nativeName: 'Eʋegbe', flagCountryCode: 'GH' }, - { code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flagCountryCode: 'GR' }, - { code: 'en', name: 'English', nativeName: 'English', flagCountryCode: 'GB' }, - { code: 'eo', name: 'Esperanto', nativeName: 'Esperanto', flagCountryCode: 'EU' }, - { code: 'es', name: 'Spanish', nativeName: 'español', flagCountryCode: 'ES' }, - { code: 'et', name: 'Estonian', nativeName: 'eesti', flagCountryCode: 'EE' }, - { code: 'eu', name: 'Basque', nativeName: 'euskara', flagCountryCode: 'ES' }, - { code: 'fa', name: 'Persian', nativeName: 'فارسی', flagCountryCode: 'IR' }, - { code: 'ff', name: 'Fulah', nativeName: 'Fulfulde', flagCountryCode: 'SN' }, - { code: 'fi', name: 'Finnish', nativeName: 'suomi', flagCountryCode: 'FI' }, - { code: 'fj', name: 'Fijian', nativeName: 'vosa Vakaviti', flagCountryCode: 'FJ' }, - { code: 'fo', name: 'Faroese', nativeName: 'føroyskt', flagCountryCode: 'FO' }, - { code: 'fr', name: 'French', nativeName: 'français', flagCountryCode: 'FR' }, - { code: 'fy', name: 'Western Frisian', nativeName: 'Frysk', flagCountryCode: 'NL' }, - { code: 'ga', name: 'Irish', nativeName: 'Gaeilge', flagCountryCode: 'IE' }, - { code: 'gd', name: 'Scottish Gaelic', nativeName: 'Gàidhlig', flagCountryCode: 'GB' }, - { code: 'gl', name: 'Galician', nativeName: 'galego', flagCountryCode: 'ES' }, - { code: 'gn', name: 'Guarani', nativeName: "Avañe'ẽ", flagCountryCode: 'PY' }, - { code: 'gu', name: 'Gujarati', nativeName: 'ગુજરાતી', flagCountryCode: 'IN' }, - { code: 'gv', name: 'Manx', nativeName: 'Gaelg', flagCountryCode: 'IM' }, - { code: 'ha', name: 'Hausa', nativeName: 'هَوُسَ', flagCountryCode: 'NG' }, - { code: 'he', name: 'Hebrew', nativeName: 'עברית', flagCountryCode: 'IL' }, - { code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flagCountryCode: 'IN' }, - { code: 'ho', name: 'Hiri Motu', nativeName: 'Hiri Motu', flagCountryCode: 'PG' }, - { code: 'hr', name: 'Croatian', nativeName: 'hrvatski jezik', flagCountryCode: 'HR' }, - { code: 'ht', name: 'Haitian', nativeName: 'Kreyòl ayisyen', flagCountryCode: 'HT' }, - { code: 'hu', name: 'Hungarian', nativeName: 'magyar', flagCountryCode: 'HU' }, - { code: 'hy', name: 'Armenian', nativeName: 'Հայերեն', flagCountryCode: 'AM' }, - { code: 'hz', name: 'Herero', nativeName: 'Otjiherero', flagCountryCode: 'NA' }, - { code: 'ia', name: 'Interlingua', nativeName: 'Interlingua', flagCountryCode: 'EU' }, - { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', flagCountryCode: 'ID' }, - { code: 'ie', name: 'Interlingue', nativeName: 'Interlingue', flagCountryCode: 'EU' }, - { code: 'ig', name: 'Igbo', nativeName: 'Asụsụ Igbo', flagCountryCode: 'NG' }, - { code: 'ii', name: 'Sichuan Yi', nativeName: 'ꆈꌠ꒿', flagCountryCode: 'CN' }, - { code: 'ik', name: 'Inupiaq', nativeName: 'Iñupiaq', flagCountryCode: 'US' }, - { code: 'io', name: 'Ido', nativeName: 'Ido', flagCountryCode: 'EU' }, - { code: 'is', name: 'Icelandic', nativeName: 'Íslenska', flagCountryCode: 'IS' }, - { code: 'it', name: 'Italian', nativeName: 'italiano', flagCountryCode: 'IT' }, - { code: 'iu', name: 'Inuktitut', nativeName: 'ᐃᓄᒃᑎᑐᑦ', flagCountryCode: 'CA' }, - { code: 'ja', name: 'Japanese', nativeName: '日本語', flagCountryCode: 'JP' }, - { code: 'jv', name: 'Javanese', nativeName: 'ꦧꦱꦗꦮ', flagCountryCode: 'ID' }, - { code: 'ka', name: 'Georgian', nativeName: 'ქართული', flagCountryCode: 'GE' }, - { code: 'kg', name: 'Kongo', nativeName: 'Kikongo', flagCountryCode: 'CD' }, - { code: 'ki', name: 'Kikuyu', nativeName: 'Gĩkũyũ', flagCountryCode: 'KE' }, - { code: 'kj', name: 'Kuanyama', nativeName: 'Kuanyama', flagCountryCode: 'AO' }, - { code: 'kk', name: 'Kazakh', nativeName: 'қазақ тілі', flagCountryCode: 'KZ' }, - { code: 'kl', name: 'Kalaallisut', nativeName: 'kalaallisut', flagCountryCode: 'GL' }, - { code: 'km', name: 'Central Khmer', nativeName: 'ខេមរភាសា', flagCountryCode: 'KH' }, - { code: 'kn', name: 'Kannada', nativeName: 'ಕನ್ನಡ', flagCountryCode: 'IN' }, - { code: 'ko', name: 'Korean', nativeName: '한국어', flagCountryCode: 'KR' }, - { code: 'kr', name: 'Kanuri', nativeName: 'Kanuri', flagCountryCode: 'NG' }, - { code: 'ks', name: 'Kashmiri', nativeName: 'कश्मीरी', flagCountryCode: 'IN' }, - { code: 'ku', name: 'Kurdish', nativeName: 'Kurdî', flagCountryCode: 'IQ' }, - { code: 'kv', name: 'Komi', nativeName: 'коми кыв', flagCountryCode: 'RU' }, - { code: 'kw', name: 'Cornish', nativeName: 'Kernewek', flagCountryCode: 'GB' }, - { code: 'ky', name: 'Kirghiz', nativeName: 'Кыргызча', flagCountryCode: 'KG' }, - { code: 'la', name: 'Latin', nativeName: 'latine', flagCountryCode: 'VA' }, - { code: 'lb', name: 'Luxembourgish', nativeName: 'Lëtzebuergesch', flagCountryCode: 'LU' }, - { code: 'lg', name: 'Ganda', nativeName: 'Luganda', flagCountryCode: 'UG' }, - { code: 'li', name: 'Limburgan', nativeName: 'Limburgs', flagCountryCode: 'NL' }, - { code: 'ln', name: 'Lingala', nativeName: 'Lingála', flagCountryCode: 'CD' }, - { code: 'lo', name: 'Lao', nativeName: 'ພາສາລາວ', flagCountryCode: 'LA' }, - { code: 'lt', name: 'Lithuanian', nativeName: 'lietuvių kalba', flagCountryCode: 'LT' }, - { code: 'lu', name: 'Luba-Katanga', nativeName: 'Tshiluba', flagCountryCode: 'CD' }, - { code: 'lv', name: 'Latvian', nativeName: 'latviešu valoda', flagCountryCode: 'LV' }, - { code: 'mg', name: 'Malagasy', nativeName: 'fiteny malagasy', flagCountryCode: 'MG' }, - { code: 'mh', name: 'Marshallese', nativeName: 'Kajin M̧ajeļ', flagCountryCode: 'MH' }, - { code: 'mi', name: 'Maori', nativeName: 'te reo Māori', flagCountryCode: 'NZ' }, - { code: 'mk', name: 'Macedonian', nativeName: 'македонски јазик', flagCountryCode: 'MK' }, - { code: 'ml', name: 'Malayalam', nativeName: 'മലയാളം', flagCountryCode: 'IN' }, - { code: 'mn', name: 'Mongolian', nativeName: 'Монгол хэл', flagCountryCode: 'MN' }, - { code: 'mr', name: 'Marathi', nativeName: 'मराठी', flagCountryCode: 'IN' }, - { code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu', flagCountryCode: 'MY' }, - { code: 'mt', name: 'Maltese', nativeName: 'Malti', flagCountryCode: 'MT' }, - { code: 'my', name: 'Burmese', nativeName: 'ဗမာစာ', flagCountryCode: 'MM' }, - { code: 'na', name: 'Nauru', nativeName: 'Dorerin Naoero', flagCountryCode: 'NR' }, - { code: 'nb', name: 'Norwegian Bokmål', nativeName: 'Norsk bokmål', flagCountryCode: 'NO' }, - { code: 'nd', name: 'North Ndebele', nativeName: 'isiNdebele', flagCountryCode: 'ZW' }, - { code: 'ne', name: 'Nepali', nativeName: 'नेपाली', flagCountryCode: 'NP' }, - { code: 'ng', name: 'Ndonga', nativeName: 'Owambo', flagCountryCode: 'NA' }, - { code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flagCountryCode: 'NL' }, - { code: 'nn', name: 'Norwegian Nynorsk', nativeName: 'Norsk nynorsk', flagCountryCode: 'NO' }, - { code: 'no', name: 'Norwegian', nativeName: 'Norsk', flagCountryCode: 'NO' }, - { code: 'nr', name: 'South Ndebele', nativeName: 'isiNdebele', flagCountryCode: 'ZA' }, - { code: 'nv', name: 'Navajo', nativeName: 'Diné bizaad', flagCountryCode: 'US' }, - { code: 'ny', name: 'Chichewa', nativeName: 'chiCheŵa', flagCountryCode: 'MW' }, - { code: 'oc', name: 'Occitan', nativeName: 'occitan', flagCountryCode: 'FR' }, - { code: 'oj', name: 'Ojibwa', nativeName: 'ᐊᓂᔑᓈᐯᒧᐎᓐ', flagCountryCode: 'CA' }, - { code: 'om', name: 'Oromo', nativeName: 'Afaan Oromoo', flagCountryCode: 'ET' }, - { code: 'or', name: 'Oriya', nativeName: 'ଓଡ଼ିଆ', flagCountryCode: 'IN' }, - { code: 'os', name: 'Ossetian', nativeName: 'ирон æвзаг', flagCountryCode: 'GE' }, - { code: 'pa', name: 'Punjabi', nativeName: 'ਪੰਜਾਬੀ', flagCountryCode: 'IN' }, - { code: 'pi', name: 'Pali', nativeName: 'पाऴि', flagCountryCode: 'IN' }, - { code: 'pl', name: 'Polish', nativeName: 'polski', flagCountryCode: 'PL' }, - { code: 'ps', name: 'Pashto', nativeName: 'پښتو', flagCountryCode: 'AF' }, - { code: 'pt', name: 'Portuguese', nativeName: 'português', flagCountryCode: 'PT' }, - { code: 'qu', name: 'Quechua', nativeName: 'Runa Simi', flagCountryCode: 'PE' }, - { code: 'rm', name: 'Romansh', nativeName: 'rumantsch grischun', flagCountryCode: 'CH' }, - { code: 'rn', name: 'Rundi', nativeName: 'Ikirundi', flagCountryCode: 'BI' }, - { code: 'ro', name: 'Romanian', nativeName: 'română', flagCountryCode: 'RO' }, - { code: 'ru', name: 'Russian', nativeName: 'русский', flagCountryCode: 'RU' }, - { code: 'rw', name: 'Kinyarwanda', nativeName: 'Ikinyarwanda', flagCountryCode: 'RW' }, - { code: 'sa', name: 'Sanskrit', nativeName: 'संस्कृतम्', flagCountryCode: 'IN' }, - { code: 'sc', name: 'Sardinian', nativeName: 'sardu', flagCountryCode: 'IT' }, - { code: 'sd', name: 'Sindhi', nativeName: 'सिन्धी', flagCountryCode: 'PK' }, - { code: 'se', name: 'Northern Sami', nativeName: 'Davvisámegiella', flagCountryCode: 'NO' }, - { code: 'sg', name: 'Sango', nativeName: 'yângâ tî sängö', flagCountryCode: 'CF' }, - { code: 'si', name: 'Sinhala', nativeName: 'සිංහල', flagCountryCode: 'LK' }, - { code: 'sk', name: 'Slovak', nativeName: 'slovenčina', flagCountryCode: 'SK' }, - { code: 'sl', name: 'Slovenian', nativeName: 'slovenski jezik', flagCountryCode: 'SI' }, - { code: 'sm', name: 'Samoan', nativeName: "gagana fa'a Samoa", flagCountryCode: 'WS' }, - { code: 'sn', name: 'Shona', nativeName: 'chiShona', flagCountryCode: 'ZW' }, - { code: 'so', name: 'Somali', nativeName: 'Soomaaliga', flagCountryCode: 'SO' }, - { code: 'sq', name: 'Albanian', nativeName: 'Shqip', flagCountryCode: 'AL' }, - { code: 'sr', name: 'Serbian', nativeName: 'српски језик', flagCountryCode: 'RS' }, - { code: 'ss', name: 'Swati', nativeName: 'SiSwati', flagCountryCode: 'SZ' }, - { code: 'st', name: 'Southern Sotho', nativeName: 'Sesotho', flagCountryCode: 'LS' }, - { code: 'su', name: 'Sundanese', nativeName: 'Basa Sunda', flagCountryCode: 'ID' }, - { code: 'sv', name: 'Swedish', nativeName: 'svenska', flagCountryCode: 'SE' }, - { code: 'sw', name: 'Swahili', nativeName: 'Kiswahili', flagCountryCode: 'TZ' }, - { code: 'ta', name: 'Tamil', nativeName: 'தமிழ்', flagCountryCode: 'IN' }, - { code: 'te', name: 'Telugu', nativeName: 'తెలుగు', flagCountryCode: 'IN' }, - { code: 'tg', name: 'Tajik', nativeName: 'тоҷикӣ', flagCountryCode: 'TJ' }, - { code: 'th', name: 'Thai', nativeName: 'ไทย', flagCountryCode: 'TH' }, - { code: 'ti', name: 'Tigrinya', nativeName: 'ትግርኛ', flagCountryCode: 'ER' }, - { code: 'tk', name: 'Turkmen', nativeName: 'Türkmen', flagCountryCode: 'TM' }, - { code: 'tl', name: 'Tagalog', nativeName: 'Wikang Tagalog', flagCountryCode: 'PH' }, - { code: 'tn', name: 'Tswana', nativeName: 'Setswana', flagCountryCode: 'BW' }, - { code: 'to', name: 'Tonga', nativeName: 'faka Tonga', flagCountryCode: 'TO' }, - { code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flagCountryCode: 'TR' }, - { code: 'ts', name: 'Tsonga', nativeName: 'Xitsonga', flagCountryCode: 'ZA' }, - { code: 'tt', name: 'Tatar', nativeName: 'татар теле', flagCountryCode: 'RU' }, - { code: 'tw', name: 'Twi', nativeName: 'Twi', flagCountryCode: 'GH' }, - { code: 'ty', name: 'Tahitian', nativeName: 'Reo Tahiti', flagCountryCode: 'PF' }, - { code: 'ug', name: 'Uighur', nativeName: 'ئۇيغۇرچە', flagCountryCode: 'CN' }, - { code: 'uk', name: 'Ukrainian', nativeName: 'Українська', flagCountryCode: 'UA' }, - { code: 'ur', name: 'Urdu', nativeName: 'اردو', flagCountryCode: 'PK' }, - { code: 'uz', name: 'Uzbek', nativeName: 'Oʻzbek', flagCountryCode: 'UZ' }, - { code: 've', name: 'Venda', nativeName: 'Tshivenḓa', flagCountryCode: 'ZA' }, - { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flagCountryCode: 'VN' }, - { code: 'vo', name: 'Volapük', nativeName: 'Volapük', flagCountryCode: 'EU' }, - { code: 'wa', name: 'Walloon', nativeName: 'walon', flagCountryCode: 'BE' }, - { code: 'wo', name: 'Wolof', nativeName: 'Wollof', flagCountryCode: 'SN' }, - { code: 'xh', name: 'Xhosa', nativeName: 'isiXhosa', flagCountryCode: 'ZA' }, - { code: 'yi', name: 'Yiddish', nativeName: 'ייִדיש', flagCountryCode: 'IL' }, - { code: 'yo', name: 'Yoruba', nativeName: 'Yorùbá', flagCountryCode: 'NG' }, - { code: 'za', name: 'Zhuang', nativeName: 'Saɯ cueŋƅ', flagCountryCode: 'CN' }, - { code: 'zh', name: 'Chinese', nativeName: '中文', flagCountryCode: 'CN' }, - { code: 'zu', name: 'Zulu', nativeName: 'isiZulu', flagCountryCode: 'ZA' }, -]; diff --git a/frontend/src/app/consts/record-edit-types.ts b/frontend/src/app/consts/record-edit-types.ts deleted file mode 100644 index 6f88204e9..000000000 --- a/frontend/src/app/consts/record-edit-types.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { BooleanEditComponent } from 'src/app/components/ui-components/record-edit-fields/boolean/boolean.component'; -import { LongTextEditComponent } from 'src/app/components/ui-components/record-edit-fields/long-text/long-text.component'; -import { NumberEditComponent } from 'src/app/components/ui-components/record-edit-fields/number/number.component'; -import { PointEditComponent } from 'src/app/components/ui-components/record-edit-fields/point/point.component'; -import { TextEditComponent } from 'src/app/components/ui-components/record-edit-fields/text/text.component'; -import { BinaryEditComponent } from '../components/ui-components/record-edit-fields/binary/binary.component'; -import { CodeEditComponent } from '../components/ui-components/record-edit-fields/code/code.component'; -import { ColorEditComponent } from '../components/ui-components/record-edit-fields/color/color.component'; -import { CountryEditComponent } from '../components/ui-components/record-edit-fields/country/country.component'; -import { DateEditComponent } from '../components/ui-components/record-edit-fields/date/date.component'; -import { DateTimeEditComponent } from '../components/ui-components/record-edit-fields/date-time/date-time.component'; -import { FileEditComponent } from '../components/ui-components/record-edit-fields/file/file.component'; -import { ForeignKeyEditComponent } from '../components/ui-components/record-edit-fields/foreign-key/foreign-key.component'; -import { IdEditComponent } from '../components/ui-components/record-edit-fields/id/id.component'; -import { ImageEditComponent } from '../components/ui-components/record-edit-fields/image/image.component'; -import { JsonEditorEditComponent } from '../components/ui-components/record-edit-fields/json-editor/json-editor.component'; -import { LanguageEditComponent } from '../components/ui-components/record-edit-fields/language/language.component'; -import { MarkdownEditComponent } from '../components/ui-components/record-edit-fields/markdown/markdown.component'; -import { MoneyEditComponent } from '../components/ui-components/record-edit-fields/money/money.component'; -import { PasswordEditComponent } from '../components/ui-components/record-edit-fields/password/password.component'; -import { PhoneEditComponent } from '../components/ui-components/record-edit-fields/phone/phone.component'; -import { RangeEditComponent } from '../components/ui-components/record-edit-fields/range/range.component'; -import { S3EditComponent } from '../components/ui-components/record-edit-fields/s3/s3.component'; -import { SelectEditComponent } from '../components/ui-components/record-edit-fields/select/select.component'; -import { StaticTextEditComponent } from '../components/ui-components/record-edit-fields/static-text/static-text.component'; -import { TimeEditComponent } from '../components/ui-components/record-edit-fields/time/time.component'; -import { TimeIntervalEditComponent } from '../components/ui-components/record-edit-fields/time-interval/time-interval.component'; -import { TimezoneEditComponent } from '../components/ui-components/record-edit-fields/timezone/timezone.component'; -import { UrlEditComponent } from '../components/ui-components/record-edit-fields/url/url.component'; -import { UuidEditComponent } from '../components/ui-components/record-edit-fields/uuid/uuid.component'; - -export const timestampTypes = [ - 'timestamp without time zone', - 'timestamp with time zone', - 'timestamp', - 'date', - 'time without time zone', - 'time with time zone', - 'time', - 'datetime', - 'date time', - 'datetime2', - 'datetimeoffset', - 'curdate', - 'curtime', - 'now', - 'localtime', - 'localtimestamp', -]; -export const defaultTimestampValues = { - postgres: ['current_date', 'current_time', 'current_timestamp', 'localtime', 'localtimestamp', 'now'], - mysql: ['curdate', 'curtime', 'now'], -}; - -export const UIwidgets = { - Default: '', - Boolean: BooleanEditComponent, - Code: CodeEditComponent, - Color: ColorEditComponent, - Country: CountryEditComponent, - Date: DateEditComponent, - DateTime: DateTimeEditComponent, - Binary: BinaryEditComponent, - File: FileEditComponent, - Foreign_key: ForeignKeyEditComponent, - Image: ImageEditComponent, - JSON: JsonEditorEditComponent, - Language: LanguageEditComponent, - Markdown: MarkdownEditComponent, - Money: MoneyEditComponent, - Number: NumberEditComponent, - Password: PasswordEditComponent, - Phone: PhoneEditComponent, - Range: RangeEditComponent, - Readonly: StaticTextEditComponent, - Select: SelectEditComponent, - String: TextEditComponent, - Textarea: LongTextEditComponent, - Time: TimeEditComponent, - Timezone: TimezoneEditComponent, - URL: UrlEditComponent, - UUID: UuidEditComponent, - S3: S3EditComponent, - Email: TextEditComponent, -}; - -export const recordEditTypes = { - postgres: { - // numbers (number) - real: NumberEditComponent, - 'double precision': NumberEditComponent, - smallint: NumberEditComponent, - integer: NumberEditComponent, - bigint: NumberEditComponent, - numeric: NumberEditComponent, - - //boolean (checkbox) - boolean: BooleanEditComponent, - - //datetime (datepicker) - 'timestamp without time zone': DateTimeEditComponent, - 'timestamp with time zone': DateTimeEditComponent, - 'time without time zone': TimeEditComponent, - 'time with time zone': TimeEditComponent, - date: DateEditComponent, - abstime: DateTimeEditComponent, - realtime: DateTimeEditComponent, - interval: TimeIntervalEditComponent, // number + select(seconds, days, weeks, years) - - // short text (text) - 'character varying': TextEditComponent, - macaddr: TextEditComponent, //to do regexp - macaddr8: TextEditComponent, //to do regexp - cidr: TextEditComponent, //to do regexp - inet: TextEditComponent, //to do regexp - uuid: UuidEditComponent, - - //long text (textarea) - text: LongTextEditComponent, - xml: LongTextEditComponent, - - //select (select) - enum: SelectEditComponent, - - // json-editor - json: JsonEditorEditComponent, //json-editor - jsonb: JsonEditorEditComponent, //json-editor - ARRAY: JsonEditorEditComponent, - - //binary - bytea: BinaryEditComponent, - - //etc - money: MoneyEditComponent, - - //mess (math) - point: PointEditComponent, - line: TextEditComponent, - circle: TextEditComponent, - path: TextEditComponent, - box: TextEditComponent, - lseg: TextEditComponent, - - 'foreign key': ForeignKeyEditComponent, - }, - - mysql: { - // numbers (number) - tinyint: NumberEditComponent, - smallint: NumberEditComponent, - mediumint: NumberEditComponent, - int: NumberEditComponent, - bigint: NumberEditComponent, - decimal: NumberEditComponent, - float: NumberEditComponent, - double: NumberEditComponent, - year: NumberEditComponent, - - //boolean (radiogroup) - boolean: BooleanEditComponent, - - //datetime (datepicker) - date: DateEditComponent, - time: TimeEditComponent, - datetime: DateTimeEditComponent, - timestamp: DateTimeEditComponent, - - // short text (text) - char: TextEditComponent, - varchar: TextEditComponent, - - //long text (textarea) - text: LongTextEditComponent, - tinytext: LongTextEditComponent, - mediumtext: LongTextEditComponent, - longtext: LongTextEditComponent, - - json: JsonEditorEditComponent, //json-editor - - //select (select) - enum: SelectEditComponent, - - //binary - binary: BinaryEditComponent, - varbinary: BinaryEditComponent, - blob: BinaryEditComponent, - tinyblob: BinaryEditComponent, - mediumblob: BinaryEditComponent, - longblob: BinaryEditComponent, - - //etc - set: TextEditComponent, //(text) - - 'foreign key': ForeignKeyEditComponent, - }, - - oracledb: { - // numbers (number) - NUMBER: NumberEditComponent, - FLOAT: NumberEditComponent, - BINARY_FLOAT: NumberEditComponent, - BINARY_DOUBLE: NumberEditComponent, - 'INTERVAL YEAR': NumberEditComponent, - 'INTERVAL DAY': NumberEditComponent, - - //datetime (datepicker) - DATE: DateEditComponent, - TIMESTAMP: DateTimeEditComponent, - - // short text (text) - CHAR: TextEditComponent, - NCHAR: TextEditComponent, - CLOB: TextEditComponent, - NCLOB: TextEditComponent, - VARCHAR2: TextEditComponent, - VARCHAR: TextEditComponent, - NVARCHAR2: TextEditComponent, - - //binary - BLOB: BinaryEditComponent, - BFILE: BinaryEditComponent, - RAW: BinaryEditComponent, - 'LONG RAW': BinaryEditComponent, - LONG: BinaryEditComponent, - - 'foreign key': ForeignKeyEditComponent, - }, - - mssql: { - // numbers (number) - bigint: NumberEditComponent, - int: NumberEditComponent, - smallint: NumberEditComponent, - tinyint: NumberEditComponent, - decimal: NumberEditComponent, - bitdecimal: NumberEditComponent, - numeric: NumberEditComponent, - real: NumberEditComponent, - - // short text (text) - uniqueidentifier: UuidEditComponent, - char: TextEditComponent, - varchar: TextEditComponent, - - //long text (textarea) - text: LongTextEditComponent, - nchar: LongTextEditComponent, - nvarchar: LongTextEditComponent, - ntext: LongTextEditComponent, - - //datetime (datepicker) - date: DateEditComponent, - datetime: DateTimeEditComponent, - smalldatetime: DateTimeEditComponent, - timestamp: DateTimeEditComponent, - - //binary - binary: BinaryEditComponent, - varbinary: BinaryEditComponent, - image: BinaryEditComponent, - - // etc - money: MoneyEditComponent, - smallmoney: MoneyEditComponent, - - 'foreign key': ForeignKeyEditComponent, - }, - mongodb: { - // numbers (number) - number: NumberEditComponent, - double: NumberEditComponent, - int32: NumberEditComponent, - long: NumberEditComponent, - decimal128: NumberEditComponent, - - //boolean (radiogroup) - boolean: BooleanEditComponent, - - //datetime (datepicker) - date: DateEditComponent, - timestamp: DateTimeEditComponent, - - // short text (text) - string: TextEditComponent, - regexp: TextEditComponent, - objectid: TextEditComponent, - - //binary - binary: BinaryEditComponent, - - //json - object: JsonEditorEditComponent, - array: JsonEditorEditComponent, - - //etc - unknown: TextEditComponent, - - 'foreign key': ForeignKeyEditComponent, - }, - dynamodb: { - string: TextEditComponent, - number: NumberEditComponent, - boolean: BooleanEditComponent, - null: StaticTextEditComponent, - array: JsonEditorEditComponent, - json: JsonEditorEditComponent, - binary: BinaryEditComponent, - }, - cassandra: { - int: NumberEditComponent, - bigint: NumberEditComponent, - varint: NumberEditComponent, - decimal: NumberEditComponent, - float: NumberEditComponent, - double: NumberEditComponent, - - boolean: BooleanEditComponent, - - timeuuid: IdEditComponent, - - timestamp: DateTimeEditComponent, - date: DateEditComponent, - time: TimeEditComponent, - - uuid: UuidEditComponent, - varchar: TextEditComponent, - inet: TextEditComponent, - ascii: TextEditComponent, - text: TextEditComponent, - - //binary - blob: BinaryEditComponent, - - list: JsonEditorEditComponent, - map: JsonEditorEditComponent, - set: JsonEditorEditComponent, - }, - redis: { - string: TextEditComponent, - integer: NumberEditComponent, - decimal: NumberEditComponent, - boolean: BooleanEditComponent, - array: JsonEditorEditComponent, - json: JsonEditorEditComponent, - }, - elasticsearch: { - string: TextEditComponent, - number: NumberEditComponent, - boolean: BooleanEditComponent, - date: DateEditComponent, - object: JsonEditorEditComponent, - array: JsonEditorEditComponent, - binary: BinaryEditComponent, - }, - clickhouse: { - string: TextEditComponent, - uuid: UuidEditComponent, - boolean: BooleanEditComponent, - integer: NumberEditComponent, - bigint: NumberEditComponent, - float: NumberEditComponent, - double: NumberEditComponent, - decimal: NumberEditComponent, - date: DateEditComponent, - datetime: DateTimeEditComponent, - json: JsonEditorEditComponent, - object: JsonEditorEditComponent, - array: JsonEditorEditComponent, - }, -}; diff --git a/frontend/src/app/consts/record-view-types.ts b/frontend/src/app/consts/record-view-types.ts deleted file mode 100644 index 3f39a0fb4..000000000 --- a/frontend/src/app/consts/record-view-types.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { BooleanRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/boolean/boolean.component'; -import { LongTextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/long-text/long-text.component'; -import { TextRecordViewComponent } from 'src/app/components/ui-components/record-view-fields/text/text.component'; -import { BinaryRecordViewComponent } from '../components/ui-components/record-view-fields/binary/binary.component'; -import { CodeRecordViewComponent } from '../components/ui-components/record-view-fields/code/code.component'; -import { ColorRecordViewComponent } from '../components/ui-components/record-view-fields/color/color.component'; -import { CountryRecordViewComponent } from '../components/ui-components/record-view-fields/country/country.component'; -import { DateRecordViewComponent } from '../components/ui-components/record-view-fields/date/date.component'; -import { DateTimeRecordViewComponent } from '../components/ui-components/record-view-fields/date-time/date-time.component'; -import { FileRecordViewComponent } from '../components/ui-components/record-view-fields/file/file.component'; -import { ForeignKeyRecordViewComponent } from '../components/ui-components/record-view-fields/foreign-key/foreign-key.component'; -import { IdRecordViewComponent } from '../components/ui-components/record-view-fields/id/id.component'; -import { ImageRecordViewComponent } from '../components/ui-components/record-view-fields/image/image.component'; -import { JsonEditorRecordViewComponent } from '../components/ui-components/record-view-fields/json-editor/json-editor.component'; -import { LanguageRecordViewComponent } from '../components/ui-components/record-view-fields/language/language.component'; -import { MarkdownRecordViewComponent } from '../components/ui-components/record-view-fields/markdown/markdown.component'; -import { MoneyRecordViewComponent } from '../components/ui-components/record-view-fields/money/money.component'; -import { NumberRecordViewComponent } from '../components/ui-components/record-view-fields/number/number.component'; -import { PasswordRecordViewComponent } from '../components/ui-components/record-view-fields/password/password.component'; -import { PhoneRecordViewComponent } from '../components/ui-components/record-view-fields/phone/phone.component'; -import { PointRecordViewComponent } from '../components/ui-components/record-view-fields/point/point.component'; -import { RangeRecordViewComponent } from '../components/ui-components/record-view-fields/range/range.component'; -import { S3RecordViewComponent } from '../components/ui-components/record-view-fields/s3/s3.component'; -import { SelectRecordViewComponent } from '../components/ui-components/record-view-fields/select/select.component'; -import { StaticTextRecordViewComponent } from '../components/ui-components/record-view-fields/static-text/static-text.component'; -import { TimeRecordViewComponent } from '../components/ui-components/record-view-fields/time/time.component'; -import { TimeIntervalRecordViewComponent } from '../components/ui-components/record-view-fields/time-interval/time-interval.component'; -import { TimezoneRecordViewComponent } from '../components/ui-components/record-view-fields/timezone/timezone.component'; -import { UrlRecordViewComponent } from '../components/ui-components/record-view-fields/url/url.component'; -import { UuidRecordViewComponent } from '../components/ui-components/record-view-fields/uuid/uuid.component'; - -export const UIwidgets = { - Default: '', - Boolean: BooleanRecordViewComponent, - Date: DateRecordViewComponent, - Time: TimeRecordViewComponent, - DateTime: DateTimeRecordViewComponent, - JSON: JsonEditorRecordViewComponent, - Language: LanguageRecordViewComponent, - Markdown: MarkdownRecordViewComponent, - Textarea: LongTextRecordViewComponent, - String: TextRecordViewComponent, - Readonly: StaticTextRecordViewComponent, - Number: NumberRecordViewComponent, - Select: SelectRecordViewComponent, - Password: PasswordRecordViewComponent, - Binary: BinaryRecordViewComponent, - File: FileRecordViewComponent, - Code: CodeRecordViewComponent, - Image: ImageRecordViewComponent, - URL: UrlRecordViewComponent, - Country: CountryRecordViewComponent, - Phone: PhoneRecordViewComponent, - Money: MoneyRecordViewComponent, - Foreign_key: ForeignKeyRecordViewComponent, - Color: ColorRecordViewComponent, - UUID: UuidRecordViewComponent, - Range: RangeRecordViewComponent, - Timezone: TimezoneRecordViewComponent, - S3: S3RecordViewComponent, -}; - -export const recordViewFieldTypes = { - postgres: { - // numbers (number) - real: NumberRecordViewComponent, - 'double precision': NumberRecordViewComponent, - smallint: NumberRecordViewComponent, - integer: NumberRecordViewComponent, - bigint: NumberRecordViewComponent, - numeric: NumberRecordViewComponent, - - //boolean (checkbox) - boolean: BooleanRecordViewComponent, - - //datetime (datepicker) - 'timestamp without time zone': DateTimeRecordViewComponent, - 'timestamp with time zone': DateTimeRecordViewComponent, - 'time without time zone': TimeRecordViewComponent, - 'time with time zone': TimeRecordViewComponent, - date: DateRecordViewComponent, - abstime: DateTimeRecordViewComponent, - realtime: DateTimeRecordViewComponent, - interval: TimeIntervalRecordViewComponent, - - // short text (text) - 'character varying': TextRecordViewComponent, - macaddr: TextRecordViewComponent, - macaddr8: TextRecordViewComponent, - cidr: TextRecordViewComponent, - inet: TextRecordViewComponent, - uuid: UuidRecordViewComponent, - - //long text (textarea) - text: LongTextRecordViewComponent, - xml: LongTextRecordViewComponent, - - //select (select) - enum: SelectRecordViewComponent, - - // json-editor - json: JsonEditorRecordViewComponent, - jsonb: JsonEditorRecordViewComponent, - ARRAY: JsonEditorRecordViewComponent, - - //binary - bytea: BinaryRecordViewComponent, - - //etc - money: MoneyRecordViewComponent, - - //mess (math) - point: PointRecordViewComponent, - line: TextRecordViewComponent, - circle: TextRecordViewComponent, - path: TextRecordViewComponent, - box: TextRecordViewComponent, - lseg: TextRecordViewComponent, - - 'foreign key': ForeignKeyRecordViewComponent, - }, - - mysql: { - // numbers (number) - tinyint: NumberRecordViewComponent, - smallint: NumberRecordViewComponent, - mediumint: NumberRecordViewComponent, - int: NumberRecordViewComponent, - bigint: NumberRecordViewComponent, - decimal: NumberRecordViewComponent, - float: NumberRecordViewComponent, - double: NumberRecordViewComponent, - year: NumberRecordViewComponent, - - //boolean (radiogroup) - boolean: BooleanRecordViewComponent, - - //datetime (datepicker) - date: DateRecordViewComponent, - time: TimeRecordViewComponent, - datetime: DateTimeRecordViewComponent, - timestamp: DateTimeRecordViewComponent, - - // short text (text) - char: TextRecordViewComponent, - varchar: TextRecordViewComponent, - - //long text (textarea) - text: LongTextRecordViewComponent, - tinytext: LongTextRecordViewComponent, - mediumtext: LongTextRecordViewComponent, - longtext: LongTextRecordViewComponent, - - json: JsonEditorRecordViewComponent, //json-editor - - //select (select) - enum: SelectRecordViewComponent, - - //binary - binary: BinaryRecordViewComponent, - varbinary: BinaryRecordViewComponent, - blob: BinaryRecordViewComponent, - tinyblob: BinaryRecordViewComponent, - mediumblob: BinaryRecordViewComponent, - longblob: BinaryRecordViewComponent, - - //etc - set: TextRecordViewComponent, - - 'foreign key': ForeignKeyRecordViewComponent, - }, - - oracledb: { - // numbers (number) - NUMBER: NumberRecordViewComponent, - FLOAT: NumberRecordViewComponent, - BINARY_FLOAT: NumberRecordViewComponent, - BINARY_DOUBLE: NumberRecordViewComponent, - 'INTERVAL YEAR': NumberRecordViewComponent, - 'INTERVAL DAY': NumberRecordViewComponent, - - //datetime (datepicker) - DATE: DateRecordViewComponent, - TIMESTAMP: DateTimeRecordViewComponent, - - // short text (text) - CHAR: TextRecordViewComponent, - NCHAR: TextRecordViewComponent, - CLOB: TextRecordViewComponent, - NCLOB: TextRecordViewComponent, - VARCHAR2: TextRecordViewComponent, - VARCHAR: TextRecordViewComponent, - NVARCHAR2: TextRecordViewComponent, - - //binary - BLOB: BinaryRecordViewComponent, - BFILE: BinaryRecordViewComponent, - RAW: BinaryRecordViewComponent, - 'LONG RAW': BinaryRecordViewComponent, - LONG: BinaryRecordViewComponent, - - 'foreign key': ForeignKeyRecordViewComponent, - }, - - mssql: { - // numbers (number) - bigint: NumberRecordViewComponent, - int: NumberRecordViewComponent, - smallint: NumberRecordViewComponent, - tinyint: NumberRecordViewComponent, - decimal: NumberRecordViewComponent, - bitdecimal: NumberRecordViewComponent, - numeric: NumberRecordViewComponent, - real: NumberRecordViewComponent, - - // short text (text) - uniqueidentifier: UuidRecordViewComponent, - char: TextRecordViewComponent, - varchar: TextRecordViewComponent, - - //long text (textarea) - text: LongTextRecordViewComponent, - nchar: LongTextRecordViewComponent, - nvarchar: LongTextRecordViewComponent, - ntext: LongTextRecordViewComponent, - - //datetime (datepicker) - date: DateRecordViewComponent, - datetime: DateTimeRecordViewComponent, - smalldatetime: DateTimeRecordViewComponent, - timestamp: DateTimeRecordViewComponent, - - //binary - binary: BinaryRecordViewComponent, - varbinary: BinaryRecordViewComponent, - image: ImageRecordViewComponent, - - // etc - money: MoneyRecordViewComponent, - smallmoney: MoneyRecordViewComponent, - - 'foreign key': ForeignKeyRecordViewComponent, - }, - mongodb: { - // numbers (number) - number: NumberRecordViewComponent, - double: NumberRecordViewComponent, - int32: NumberRecordViewComponent, - long: NumberRecordViewComponent, - decimal128: NumberRecordViewComponent, - - //boolean (radiogroup) - boolean: BooleanRecordViewComponent, - - //datetime (datepicker) - date: DateRecordViewComponent, - timestamp: DateTimeRecordViewComponent, - - // short text (text) - string: TextRecordViewComponent, - regexp: TextRecordViewComponent, - objectid: TextRecordViewComponent, - - //binary - binary: BinaryRecordViewComponent, - - //json - object: JsonEditorRecordViewComponent, - array: JsonEditorRecordViewComponent, - - //etc - unknown: TextRecordViewComponent, - - 'foreign key': ForeignKeyRecordViewComponent, - }, - dynamodb: { - string: TextRecordViewComponent, - number: NumberRecordViewComponent, - boolean: BooleanRecordViewComponent, - null: StaticTextRecordViewComponent, - array: JsonEditorRecordViewComponent, - json: JsonEditorRecordViewComponent, - binary: BinaryRecordViewComponent, - }, - cassandra: { - int: NumberRecordViewComponent, - bigint: NumberRecordViewComponent, - varint: NumberRecordViewComponent, - decimal: NumberRecordViewComponent, - float: NumberRecordViewComponent, - double: NumberRecordViewComponent, - - boolean: BooleanRecordViewComponent, - - timeuuid: IdRecordViewComponent, - - timestamp: DateTimeRecordViewComponent, - date: DateRecordViewComponent, - time: TimeRecordViewComponent, - - uuid: UuidRecordViewComponent, - varchar: TextRecordViewComponent, - inet: TextRecordViewComponent, - ascii: TextRecordViewComponent, - text: LongTextRecordViewComponent, - - //binary - blob: BinaryRecordViewComponent, - - list: JsonEditorRecordViewComponent, - map: JsonEditorRecordViewComponent, - set: JsonEditorRecordViewComponent, - }, - redis: { - string: TextRecordViewComponent, - integer: NumberRecordViewComponent, - decimal: NumberRecordViewComponent, - boolean: BooleanRecordViewComponent, - array: JsonEditorRecordViewComponent, - json: JsonEditorRecordViewComponent, - }, - elasticsearch: { - string: TextRecordViewComponent, - number: NumberRecordViewComponent, - boolean: BooleanRecordViewComponent, - date: DateRecordViewComponent, - object: JsonEditorRecordViewComponent, - array: JsonEditorRecordViewComponent, - binary: BinaryRecordViewComponent, - }, - clickhouse: { - string: TextRecordViewComponent, - uuid: UuidRecordViewComponent, - boolean: BooleanRecordViewComponent, - integer: NumberRecordViewComponent, - bigint: NumberRecordViewComponent, - float: NumberRecordViewComponent, - double: NumberRecordViewComponent, - decimal: NumberRecordViewComponent, - date: DateRecordViewComponent, - datetime: DateTimeRecordViewComponent, - json: JsonEditorRecordViewComponent, - object: JsonEditorRecordViewComponent, - array: JsonEditorRecordViewComponent, - }, -}; diff --git a/frontend/src/app/consts/table-display-types.ts b/frontend/src/app/consts/table-display-types.ts deleted file mode 100644 index 22b6c62b9..000000000 --- a/frontend/src/app/consts/table-display-types.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { BooleanDisplayComponent } from 'src/app/components/ui-components/table-display-fields/boolean/boolean.component'; -import { LongTextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/long-text/long-text.component'; -import { TextDisplayComponent } from 'src/app/components/ui-components/table-display-fields/text/text.component'; -import { BinaryDisplayComponent } from '../components/ui-components/table-display-fields/binary/binary.component'; -import { CodeDisplayComponent } from '../components/ui-components/table-display-fields/code/code.component'; -import { ColorDisplayComponent } from '../components/ui-components/table-display-fields/color/color.component'; -import { CountryDisplayComponent } from '../components/ui-components/table-display-fields/country/country.component'; -import { DateDisplayComponent } from '../components/ui-components/table-display-fields/date/date.component'; -import { DateTimeDisplayComponent } from '../components/ui-components/table-display-fields/date-time/date-time.component'; -import { FileDisplayComponent } from '../components/ui-components/table-display-fields/file/file.component'; -import { ForeignKeyDisplayComponent } from '../components/ui-components/table-display-fields/foreign-key/foreign-key.component'; -import { IdDisplayComponent } from '../components/ui-components/table-display-fields/id/id.component'; -import { ImageDisplayComponent } from '../components/ui-components/table-display-fields/image/image.component'; -import { JsonEditorDisplayComponent } from '../components/ui-components/table-display-fields/json-editor/json-editor.component'; -import { LanguageDisplayComponent } from '../components/ui-components/table-display-fields/language/language.component'; -import { MarkdownDisplayComponent } from '../components/ui-components/table-display-fields/markdown/markdown.component'; -import { MoneyDisplayComponent } from '../components/ui-components/table-display-fields/money/money.component'; -import { NumberDisplayComponent } from '../components/ui-components/table-display-fields/number/number.component'; -import { PasswordDisplayComponent } from '../components/ui-components/table-display-fields/password/password.component'; -import { PhoneDisplayComponent } from '../components/ui-components/table-display-fields/phone/phone.component'; -import { PointDisplayComponent } from '../components/ui-components/table-display-fields/point/point.component'; -import { RangeDisplayComponent } from '../components/ui-components/table-display-fields/range/range.component'; -import { S3DisplayComponent } from '../components/ui-components/table-display-fields/s3/s3.component'; -import { SelectDisplayComponent } from '../components/ui-components/table-display-fields/select/select.component'; -import { StaticTextDisplayComponent } from '../components/ui-components/table-display-fields/static-text/static-text.component'; -import { TimeDisplayComponent } from '../components/ui-components/table-display-fields/time/time.component'; -import { TimeIntervalDisplayComponent } from '../components/ui-components/table-display-fields/time-interval/time-interval.component'; -import { TimezoneDisplayComponent } from '../components/ui-components/table-display-fields/timezone/timezone.component'; -import { UrlDisplayComponent } from '../components/ui-components/table-display-fields/url/url.component'; -import { UuidDisplayComponent } from '../components/ui-components/table-display-fields/uuid/uuid.component'; - -export const UIwidgets = { - Default: '', - Boolean: BooleanDisplayComponent, - Code: CodeDisplayComponent, - Color: ColorDisplayComponent, - Country: CountryDisplayComponent, - Date: DateDisplayComponent, - DateTime: DateTimeDisplayComponent, - Binary: BinaryDisplayComponent, - File: FileDisplayComponent, - Foreign_key: ForeignKeyDisplayComponent, - Image: ImageDisplayComponent, - JSON: JsonEditorDisplayComponent, - Language: LanguageDisplayComponent, - Markdown: MarkdownDisplayComponent, - Money: MoneyDisplayComponent, - Number: NumberDisplayComponent, - Password: PasswordDisplayComponent, - Phone: PhoneDisplayComponent, - Range: RangeDisplayComponent, - Readonly: StaticTextDisplayComponent, - Select: SelectDisplayComponent, - String: TextDisplayComponent, - Textarea: LongTextDisplayComponent, - Time: TimeDisplayComponent, - Timezone: TimezoneDisplayComponent, - URL: UrlDisplayComponent, - UUID: UuidDisplayComponent, - S3: S3DisplayComponent, - Email: TextDisplayComponent, -}; - -export const tableDisplayTypes = { - postgres: { - // numbers (number) - real: NumberDisplayComponent, - 'double precision': NumberDisplayComponent, - smallint: NumberDisplayComponent, - integer: NumberDisplayComponent, - bigint: NumberDisplayComponent, - numeric: NumberDisplayComponent, - - //boolean (checkbox) - boolean: BooleanDisplayComponent, - - //datetime (datepicker) - 'timestamp without time zone': DateTimeDisplayComponent, - 'timestamp with time zone': DateTimeDisplayComponent, - 'time without time zone': TimeDisplayComponent, - 'time with time zone': TimeDisplayComponent, - date: DateDisplayComponent, - abstime: DateTimeDisplayComponent, - realtime: DateTimeDisplayComponent, - interval: TimeIntervalDisplayComponent, - - // short text (text) - 'character varying': TextDisplayComponent, - macaddr: TextDisplayComponent, - macaddr8: TextDisplayComponent, - cidr: TextDisplayComponent, - inet: TextDisplayComponent, - uuid: UuidDisplayComponent, - - //long text (textarea) - text: LongTextDisplayComponent, - xml: LongTextDisplayComponent, - - //select (select) - enum: SelectDisplayComponent, - - // json-editor - json: JsonEditorDisplayComponent, - jsonb: JsonEditorDisplayComponent, - ARRAY: JsonEditorDisplayComponent, - - //binary - bytea: BinaryDisplayComponent, - - //etc - money: MoneyDisplayComponent, - - //mess (math) - point: PointDisplayComponent, - line: TextDisplayComponent, - circle: TextDisplayComponent, - path: TextDisplayComponent, - box: TextDisplayComponent, - lseg: TextDisplayComponent, - - 'foreign key': ForeignKeyDisplayComponent, - }, - - mysql: { - // numbers (number) - tinyint: NumberDisplayComponent, - smallint: NumberDisplayComponent, - mediumint: NumberDisplayComponent, - int: NumberDisplayComponent, - bigint: NumberDisplayComponent, - decimal: NumberDisplayComponent, - float: NumberDisplayComponent, - double: NumberDisplayComponent, - year: NumberDisplayComponent, - - //boolean (radiogroup) - boolean: BooleanDisplayComponent, - - //datetime (datepicker) - date: DateDisplayComponent, - time: TimeDisplayComponent, - datetime: DateTimeDisplayComponent, - timestamp: DateTimeDisplayComponent, - - // short text (text) - char: TextDisplayComponent, - varchar: TextDisplayComponent, - - //long text (textarea) - text: LongTextDisplayComponent, - tinytext: LongTextDisplayComponent, - mediumtext: LongTextDisplayComponent, - longtext: LongTextDisplayComponent, - - json: JsonEditorDisplayComponent, //json-editor - - //select (select) - enum: SelectDisplayComponent, - - //binary - binary: BinaryDisplayComponent, - varbinary: BinaryDisplayComponent, - blob: BinaryDisplayComponent, - tinyblob: BinaryDisplayComponent, - mediumblob: BinaryDisplayComponent, - longblob: BinaryDisplayComponent, - - //etc - set: TextDisplayComponent, - - 'foreign key': ForeignKeyDisplayComponent, - }, - - oracledb: { - // numbers (number) - NUMBER: NumberDisplayComponent, - FLOAT: NumberDisplayComponent, - BINARY_FLOAT: NumberDisplayComponent, - BINARY_DOUBLE: NumberDisplayComponent, - 'INTERVAL YEAR': NumberDisplayComponent, - 'INTERVAL DAY': NumberDisplayComponent, - - //datetime (datepicker) - DATE: DateDisplayComponent, - TIMESTAMP: DateTimeDisplayComponent, - - // short text (text) - CHAR: TextDisplayComponent, - NCHAR: TextDisplayComponent, - CLOB: TextDisplayComponent, - NCLOB: TextDisplayComponent, - VARCHAR2: TextDisplayComponent, - VARCHAR: TextDisplayComponent, - NVARCHAR2: TextDisplayComponent, - - //binary - BLOB: BinaryDisplayComponent, - BFILE: BinaryDisplayComponent, - RAW: BinaryDisplayComponent, - 'LONG RAW': BinaryDisplayComponent, - LONG: BinaryDisplayComponent, - - 'foreign key': ForeignKeyDisplayComponent, - }, - - mssql: { - // numbers (number) - bigint: NumberDisplayComponent, - int: NumberDisplayComponent, - smallint: NumberDisplayComponent, - tinyint: NumberDisplayComponent, - decimal: NumberDisplayComponent, - bitdecimal: NumberDisplayComponent, - numeric: NumberDisplayComponent, - real: NumberDisplayComponent, - - // short text (text) - uniqueidentifier: UuidDisplayComponent, - char: TextDisplayComponent, - varchar: TextDisplayComponent, - - //long text (textarea) - text: LongTextDisplayComponent, - nchar: LongTextDisplayComponent, - nvarchar: LongTextDisplayComponent, - ntext: LongTextDisplayComponent, - - //datetime (datepicker) - date: DateDisplayComponent, - datetime: DateTimeDisplayComponent, - smalldatetime: DateTimeDisplayComponent, - timestamp: DateTimeDisplayComponent, - - //binary - binary: BinaryDisplayComponent, - varbinary: BinaryDisplayComponent, - image: ImageDisplayComponent, - - // etc - money: MoneyDisplayComponent, - smallmoney: MoneyDisplayComponent, - - 'foreign key': ForeignKeyDisplayComponent, - }, - mongodb: { - // numbers (number) - number: NumberDisplayComponent, - double: NumberDisplayComponent, - int32: NumberDisplayComponent, - long: NumberDisplayComponent, - decimal128: NumberDisplayComponent, - - //boolean (radiogroup) - boolean: BooleanDisplayComponent, - - //datetime (datepicker) - date: DateDisplayComponent, - timestamp: DateTimeDisplayComponent, - - // short text (text) - string: TextDisplayComponent, - regexp: TextDisplayComponent, - objectid: TextDisplayComponent, - - //binary - binary: BinaryDisplayComponent, - - //json - object: JsonEditorDisplayComponent, - array: JsonEditorDisplayComponent, - - //etc - unknown: TextDisplayComponent, - - 'foreign key': ForeignKeyDisplayComponent, - }, - dynamodb: { - string: TextDisplayComponent, - number: NumberDisplayComponent, - boolean: BooleanDisplayComponent, - null: StaticTextDisplayComponent, - array: JsonEditorDisplayComponent, - json: JsonEditorDisplayComponent, - binary: BinaryDisplayComponent, - }, - cassandra: { - int: NumberDisplayComponent, - bigint: NumberDisplayComponent, - varint: NumberDisplayComponent, - decimal: NumberDisplayComponent, - float: NumberDisplayComponent, - double: NumberDisplayComponent, - - boolean: BooleanDisplayComponent, - - timeuuid: IdDisplayComponent, - - timestamp: DateTimeDisplayComponent, - date: DateDisplayComponent, - time: TimeDisplayComponent, - - uuid: UuidDisplayComponent, - varchar: TextDisplayComponent, - inet: TextDisplayComponent, - ascii: TextDisplayComponent, - text: LongTextDisplayComponent, - - //binary - blob: BinaryDisplayComponent, - - list: JsonEditorDisplayComponent, - map: JsonEditorDisplayComponent, - set: JsonEditorDisplayComponent, - }, - redis: { - string: TextDisplayComponent, - integer: NumberDisplayComponent, - decimal: NumberDisplayComponent, - boolean: BooleanDisplayComponent, - array: JsonEditorDisplayComponent, - json: JsonEditorDisplayComponent, - }, - elasticsearch: { - string: TextDisplayComponent, - number: NumberDisplayComponent, - boolean: BooleanDisplayComponent, - date: DateDisplayComponent, - object: JsonEditorDisplayComponent, - array: JsonEditorDisplayComponent, - binary: BinaryDisplayComponent, - }, - clickhouse: { - string: TextDisplayComponent, - uuid: UuidDisplayComponent, - boolean: BooleanDisplayComponent, - integer: NumberDisplayComponent, - bigint: NumberDisplayComponent, - float: NumberDisplayComponent, - double: NumberDisplayComponent, - decimal: NumberDisplayComponent, - date: DateDisplayComponent, - datetime: DateTimeDisplayComponent, - json: JsonEditorDisplayComponent, - object: JsonEditorDisplayComponent, - array: JsonEditorDisplayComponent, - }, -}; diff --git a/frontend/src/app/directives/base64Validator.directive.ts b/frontend/src/app/directives/base64Validator.directive.ts deleted file mode 100644 index c18839050..000000000 --- a/frontend/src/app/directives/base64Validator.directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Directive } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { base64Validation } from '../validators/base64.validator'; - -@Directive({ - selector: '[base64Validator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: Base64ValidationDirective, - multi: true, - }, - ], -}) -export class Base64ValidationDirective implements Validator { - validate(control: AbstractControl): ValidationErrors | null { - return base64Validation()(control); - } -} diff --git a/frontend/src/app/directives/connection-string-validator.directive.ts b/frontend/src/app/directives/connection-string-validator.directive.ts deleted file mode 100644 index 02bb48a8c..000000000 --- a/frontend/src/app/directives/connection-string-validator.directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Directive } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { connectionStringValidation } from '../validators/connection-string.validator'; - -@Directive({ - selector: '[connectionStringValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: ConnectionStringValidatorDirective, - multi: true, - }, - ], -}) -export class ConnectionStringValidatorDirective implements Validator { - validate(control: AbstractControl): ValidationErrors | null { - return connectionStringValidation()(control); - } -} diff --git a/frontend/src/app/directives/emailValidator.directive.ts b/frontend/src/app/directives/emailValidator.directive.ts deleted file mode 100644 index 42e44e36e..000000000 --- a/frontend/src/app/directives/emailValidator.directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Directive } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { emailValidation } from '../validators/email.validator'; - -@Directive({ - selector: '[emailValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: EmailValidationDirective, - multi: true, - }, - ], -}) -export class EmailValidationDirective implements Validator { - validate(control: AbstractControl): ValidationErrors | null { - return emailValidation()(control); - } -} diff --git a/frontend/src/app/directives/hexValidator.directive.ts b/frontend/src/app/directives/hexValidator.directive.ts deleted file mode 100644 index 8bc14133a..000000000 --- a/frontend/src/app/directives/hexValidator.directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Directive } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { hexValidation } from '../validators/hex.validator'; - -@Directive({ - selector: '[hexValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: HexValidationDirective, - multi: true, - }, - ], -}) -export class HexValidationDirective implements Validator { - validate(control: AbstractControl): ValidationErrors | null { - return hexValidation()(control); - } -} diff --git a/frontend/src/app/directives/hostnameValidator.directive.ts b/frontend/src/app/directives/hostnameValidator.directive.ts deleted file mode 100644 index 0df2e0dd3..000000000 --- a/frontend/src/app/directives/hostnameValidator.directive.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Directive, Input } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { DBtype } from '../models/connection'; -import { hostnameValidation } from '../validators/hostname.validator'; - -@Directive({ - selector: '[hostnameValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: HostnameValidationDirective, - multi: true, - }, - ], -}) -export class HostnameValidationDirective implements Validator { - @Input() hostnameValidator: DBtype; - - validate(control: AbstractControl): ValidationErrors | null { - return hostnameValidation(this.hostnameValidator)(control); - } -} diff --git a/frontend/src/app/directives/ngmat-table-query-reflector.directive.ts b/frontend/src/app/directives/ngmat-table-query-reflector.directive.ts deleted file mode 100644 index 5e2e3a5e1..000000000 --- a/frontend/src/app/directives/ngmat-table-query-reflector.directive.ts +++ /dev/null @@ -1,159 +0,0 @@ -// MIT License - -// Copyright (c) 2020 Md. Abdun Nahid - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Directive, Input, OnDestroy, OnInit } from '@angular/core'; -import { PageEvent } from '@angular/material/paginator'; -import { MatSortable, Sort } from '@angular/material/sort'; -import { MatTableDataSource } from '@angular/material/table'; -import { ActivatedRoute, Router } from '@angular/router'; -import { interval, Subject, Subscription } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -@Directive({ - selector: '[NgMatTableQueryReflector]', -}) -export class NgMatTableQueryReflectorDirective implements OnInit, OnDestroy { - private unsubscribeAll$: Subject = new Subject(); - - @Input() matSortActive: string; - @Input() matSortDirection: 'asc' | 'desc'; - @Input() dataSource: MatTableDataSource; - private _dataSourceChecker$: Subscription; - - constructor( - private _router: Router, - private _activatedRoute: ActivatedRoute, - ) {} - - async ngOnInit(): Promise { - await this.waitForDatasourceToLoad(); - this._initialSetup(); - this.listenToStateChangeEvents(); - } - - private _initialSetup(): void { - const activePageQuery = this.isPageQueryActive(); - - if (activePageQuery) { - this.dataSource.paginator.pageIndex = activePageQuery.page_index; - this.dataSource.paginator.pageSize = activePageQuery.page_size; - } - - // Activating initial Sort - const activeSortQuery = this.isSortQueryActive(); - if (activeSortQuery) { - const sortActiveColumn = activeSortQuery - ? activeSortQuery.sort_direction - ? activeSortQuery.sort_active - : null - : this.matSortActive; - const sortable: MatSortable = { - id: sortActiveColumn, - start: activeSortQuery ? activeSortQuery.sort_direction || null : this.matSortDirection, - disableClear: true, - }; - this.dataSource.sort.sort(sortable); - - if (!sortActiveColumn) { - return; - } - // Material Sort Issue: https://github.com/angular/components/issues/10242 - // Picked a hack from: https://github.com/angular/components/issues/10242#issuecomment-421490991 - const activeSortHeader = this.dataSource.sort.sortables.get(sortActiveColumn); - if (!activeSortHeader) { - return; - } - } - } - - private isSortQueryActive(): { sort_active: string; sort_direction: 'asc' | 'desc' } { - const queryParams = this._activatedRoute.snapshot.queryParams; - - if (Object.hasOwn(queryParams, 'sort_active') || Object.hasOwn(queryParams, 'sort_direction')) { - return { - sort_active: queryParams.sort_active, - sort_direction: queryParams.sort_direction, - }; - } - - return; - } - - private isPageQueryActive(): { page_size: number; page_index: number } { - const queryParams = this._activatedRoute.snapshot.queryParams; - - if (Object.hasOwn(queryParams, 'page_size') || Object.hasOwn(queryParams, 'page_index')) { - return { - page_size: queryParams.page_size, - page_index: queryParams.page_index, - }; - } - - return; - } - - private listenToStateChangeEvents(): void { - this.dataSource.sort.sortChange.pipe(takeUntil(this.unsubscribeAll$)).subscribe((sortChange: Sort) => { - this._applySortChangesToUrlQueryParams(sortChange); - }); - - this.dataSource.paginator.page.pipe(takeUntil(this.unsubscribeAll$)).subscribe((pageChange: PageEvent) => { - this._applyPageStateChangesToUrlQueryParams(pageChange); - }); - } - - private _applySortChangesToUrlQueryParams(sortChange: Sort): void { - const sortingAndPaginationQueryParams = { - sort_active: sortChange.active, - sort_direction: sortChange.direction, - }; - - this._router.navigate([], { queryParams: sortingAndPaginationQueryParams, queryParamsHandling: 'merge' }); - } - - private _applyPageStateChangesToUrlQueryParams(pageChange: PageEvent): void { - const sortingAndPaginationQueryParams = { - page_size: pageChange.pageSize, - page_index: pageChange.pageIndex, - }; - - this._router.navigate([], { queryParams: sortingAndPaginationQueryParams, queryParamsHandling: 'merge' }); - } - - private waitForDatasourceToLoad(): Promise { - const titleCheckingInterval$ = interval(500); - - return new Promise((resolve) => { - this._dataSourceChecker$ = titleCheckingInterval$.subscribe((_val) => { - if (this.dataSource?.sort && this.dataSource?.paginator) { - this._dataSourceChecker$.unsubscribe(); - return resolve(); - } - }); - }); - } - - ngOnDestroy(): void { - this.unsubscribeAll$.next(null); - this.unsubscribeAll$.complete(); - } -} diff --git a/frontend/src/app/directives/passwordValidator.directive.ts b/frontend/src/app/directives/passwordValidator.directive.ts deleted file mode 100644 index 6bcdad858..000000000 --- a/frontend/src/app/directives/passwordValidator.directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Directive } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { passwordValidation } from '../validators/password.validator'; - -@Directive({ - selector: '[passwordValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: PasswordValidationDirective, - multi: true, - }, - ], -}) -export class PasswordValidationDirective implements Validator { - validate(control: AbstractControl): ValidationErrors | null { - return passwordValidation()(control); - } -} diff --git a/frontend/src/app/directives/text-validator.directive.ts b/frontend/src/app/directives/text-validator.directive.ts deleted file mode 100644 index e4dea3f50..000000000 --- a/frontend/src/app/directives/text-validator.directive.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Directive, Input } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; - -import { textValidation } from '../validators/text.validator'; - -@Directive({ - selector: '[textValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: TextValidatorDirective, - multi: true, - }, - ], -}) -export class TextValidatorDirective implements Validator { - @Input('validateType') validateType: string | null = null; - @Input('regexPattern') regexPattern: string | null = null; - - validate(control: AbstractControl): ValidationErrors | null { - return textValidation(this.validateType, this.regexPattern)(control); - } -} diff --git a/frontend/src/app/directives/url-validator.directive.spec.ts b/frontend/src/app/directives/url-validator.directive.spec.ts deleted file mode 100644 index 770229021..000000000 --- a/frontend/src/app/directives/url-validator.directive.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { UrlValidatorDirective } from './url-validator.directive'; - -describe('UrlValidatorDirectiveTsDirective', () => { - it('should create an instance', () => { - const directive = new UrlValidatorDirective(); - expect(directive).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/directives/url-validator.directive.ts b/frontend/src/app/directives/url-validator.directive.ts deleted file mode 100644 index ea0f7d6d8..000000000 --- a/frontend/src/app/directives/url-validator.directive.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Directive, Input } from '@angular/core'; -import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; -import { urlValidation } from '../validators/url.validator'; - -@Directive({ - selector: '[urlValidator][ngModel]', - providers: [ - { - provide: NG_VALIDATORS, - useExisting: UrlValidatorDirective, - multi: true, - }, - ], -}) -export class UrlValidatorDirective implements Validator { - @Input('urlPrefix') prefix: string = ''; - - validate(control: AbstractControl): ValidationErrors | null { - return urlValidation(this.prefix)(control); - } -} diff --git a/frontend/src/app/guards/auth.guard.spec.ts b/frontend/src/app/guards/auth.guard.spec.ts deleted file mode 100644 index 4c2356a9d..000000000 --- a/frontend/src/app/guards/auth.guard.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { inject, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AuthGuard } from './auth.guard'; - -describe('AuthGuard', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [AuthGuard], - imports: [RouterTestingModule], - }); - }); - - it('should ...', inject([AuthGuard], (guard: AuthGuard) => { - expect(guard).toBeTruthy(); - })); -}); diff --git a/frontend/src/app/guards/auth.guard.ts b/frontend/src/app/guards/auth.guard.ts deleted file mode 100644 index 4f08977a7..000000000 --- a/frontend/src/app/guards/auth.guard.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; -import { differenceInMilliseconds } from 'date-fns'; - -@Injectable({ - providedIn: 'root', -}) -export class AuthGuard implements CanActivate { - constructor(private router: Router) {} - - async canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) { - try { - const expirationToken = localStorage.getItem('token_expiration'); - let expirationTime = null; - if (expirationToken) expirationTime = new Date(expirationToken); - const currantTime = new Date(); - - if (expirationTime && currantTime) { - const expirationInterval = differenceInMilliseconds(expirationTime, currantTime); - if (expirationInterval > 0) return true; - } else { - console.log('auth guard, expirationInterval <= 0'); - this.router.navigate(['/login']); - } - return false; - } catch { - return false; - } - } -} diff --git a/frontend/src/app/guards/configuration.guard.ts b/frontend/src/app/guards/configuration.guard.ts deleted file mode 100644 index 5d41ed184..000000000 --- a/frontend/src/app/guards/configuration.guard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { SelfhostedService } from '../services/selfhosted.service'; - -/** - * Guard that protects the /login route. - * In self-hosted mode, redirects to /setup if the app is not configured. - * In SaaS mode, allows access immediately. - */ -export const configurationGuard: CanActivateFn = async () => { - const selfhostedService = inject(SelfhostedService); - const router = inject(Router); - - // In SaaS mode, always allow access to login - if (!selfhostedService.isSelfHosted()) { - return true; - } - - // If we already know the configuration state, use it - const currentState = selfhostedService.isConfigured(); - if (currentState !== null) { - if (currentState) { - return true; - } else { - return router.createUrlTree(['/setup']); - } - } - - // Check configuration from the server - const response = await selfhostedService.checkConfiguration(); - if (response.isConfigured) { - return true; - } else { - return router.createUrlTree(['/setup']); - } -}; diff --git a/frontend/src/app/guards/no-auth.guard.ts b/frontend/src/app/guards/no-auth.guard.ts deleted file mode 100644 index 026069d35..000000000 --- a/frontend/src/app/guards/no-auth.guard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { differenceInMilliseconds } from 'date-fns'; - -/** - * Guard that prevents logged-in users from accessing auth pages (login). - * Redirects authenticated users to /connections-list. - */ -export const noAuthGuard: CanActivateFn = () => { - const router = inject(Router); - - try { - const expirationToken = localStorage.getItem('token_expiration'); - if (expirationToken) { - const expirationTime = new Date(expirationToken); - const expirationInterval = differenceInMilliseconds(expirationTime, new Date()); - if (expirationInterval > 0) { - return router.createUrlTree(['/connections-list']); - } - } - } catch { - // If anything fails, allow access to auth pages - } - - return true; -}; diff --git a/frontend/src/app/guards/setup.guard.ts b/frontend/src/app/guards/setup.guard.ts deleted file mode 100644 index 717a024c3..000000000 --- a/frontend/src/app/guards/setup.guard.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { SelfhostedService } from '../services/selfhosted.service'; - -/** - * Guard that protects the /setup route. - * - In SaaS mode, redirects to /login (setup is only for self-hosted) - * - In self-hosted mode, redirects to /login if already configured - * - Allows access to /setup only if self-hosted and not configured - */ -export const setupGuard: CanActivateFn = async () => { - const selfhostedService = inject(SelfhostedService); - const router = inject(Router); - - // In SaaS mode, redirect to login (setup is only for self-hosted) - if (!selfhostedService.isSelfHosted()) { - return router.createUrlTree(['/login']); - } - - // If we already know the configuration state, use it - const currentState = selfhostedService.isConfigured(); - if (currentState !== null) { - if (currentState) { - // Already configured, redirect to login - return router.createUrlTree(['/login']); - } else { - // Not configured, allow access to setup - return true; - } - } - - // Check configuration from the server - const response = await selfhostedService.checkConfiguration(); - if (response.isConfigured) { - // Already configured, redirect to login - return router.createUrlTree(['/login']); - } else { - // Not configured, allow access to setup - return true; - } -}; diff --git a/frontend/src/app/lib/binary.spec.ts b/frontend/src/app/lib/binary.spec.ts deleted file mode 100644 index 88bdd2c42..000000000 --- a/frontend/src/app/lib/binary.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - base64StringToBytes, - bytesToAscii, - bytesToBase64, - bytesToEncoded, - bytesToHex, - encodedToBytes, - hexStringToBytes, - isBinaryEncoding, - parseBinaryValue, - stringToBytes, - toBufferJson, -} from './binary'; - -describe('binary helpers', () => { - describe('parseBinaryValue', () => { - it('returns empty for null/undefined/empty', () => { - expect(parseBinaryValue(null)).toEqual([]); - expect(parseBinaryValue(undefined)).toEqual([]); - expect(parseBinaryValue('')).toEqual([]); - }); - - it('parses a string as char-code-per-byte', () => { - expect(parseBinaryValue('Hello')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]); - }); - - it('truncates char codes above 0xff to a byte', () => { - expect(parseBinaryValue('ÿĀ')).toEqual([0xff, 0x00]); - }); - - it('extracts data from a Buffer-JSON value', () => { - expect(parseBinaryValue({ type: 'Buffer', data: [0x48, 0x65] })).toEqual([0x48, 0x65]); - }); - - it('extracts data from a Uint8Array', () => { - expect(parseBinaryValue(new Uint8Array([1, 2, 3]))).toEqual([1, 2, 3]); - }); - }); - - describe('stringToBytes', () => { - it('maps each char to its 8-bit code', () => { - expect(stringToBytes('Hi')).toEqual([0x48, 0x69]); - }); - }); - - describe('hexStringToBytes', () => { - it('parses even-length hex', () => { - expect(hexStringToBytes('48656c6c6f')).toEqual([0x48, 0x65, 0x6c, 0x6c, 0x6f]); - }); - - it('left-pads odd-length hex', () => { - expect(hexStringToBytes('abc')).toEqual([0x0a, 0xbc]); - }); - - it('returns empty for malformed hex', () => { - expect(hexStringToBytes('zz')).toEqual([]); - }); - - it('returns empty for empty input', () => { - expect(hexStringToBytes('')).toEqual([]); - }); - }); - - describe('bytesToHex', () => { - it('formats bytes with zero-padded lowercase hex', () => { - expect(bytesToHex([0x01, 0xab, 0x00])).toBe('01ab00'); - }); - - it('returns empty string for empty array', () => { - expect(bytesToHex([])).toBe(''); - }); - }); - - describe('bytesToBase64 / base64StringToBytes', () => { - it('round-trips ASCII "Hello" as SGVsbG8=', () => { - const bytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; - expect(bytesToBase64(bytes)).toBe('SGVsbG8='); - expect(base64StringToBytes('SGVsbG8=')).toEqual(bytes); - }); - - it('encodes empty bytes to empty string', () => { - expect(bytesToBase64([])).toBe(''); - expect(base64StringToBytes('')).toEqual([]); - }); - - it('handles arbitrary binary bytes including 0x00 and 0xff', () => { - const bytes = [0x00, 0x10, 0x80, 0xff]; - const encoded = bytesToBase64(bytes); - expect(base64StringToBytes(encoded)).toEqual(bytes); - }); - - it('returns null for invalid base64', () => { - expect(base64StringToBytes('not base64!')).toBeNull(); - expect(base64StringToBytes('SGVsbG8')).toBeNull(); // missing padding - }); - - it('encodes a long payload via chunked path', () => { - const bytes = new Array(10000).fill(0).map((_, i) => i & 0xff); - const encoded = bytesToBase64(bytes); - expect(base64StringToBytes(encoded)).toEqual(bytes); - }); - }); - - describe('bytesToAscii', () => { - it('renders printable bytes as-is', () => { - expect(bytesToAscii([0x48, 0x69, 0x21])).toBe('Hi!'); - }); - - it('replaces non-printable bytes with a dot', () => { - expect(bytesToAscii([0x00, 0x48, 0x1f, 0x7f, 0xff, 0x80])).toBe('.H....'); - }); - - it('keeps tab, LF, CR as-is', () => { - expect(bytesToAscii([0x09, 0x0a, 0x0d, 0x41])).toBe('\t\n\rA'); - }); - }); - - describe('bytesToEncoded / encodedToBytes', () => { - it('hex round-trip', () => { - const bytes = [0x01, 0xab, 0x00]; - const enc = bytesToEncoded(bytes, 'hex'); - expect(enc).toBe('01ab00'); - expect(encodedToBytes(enc, 'hex')).toEqual(bytes); - }); - - it('base64 round-trip', () => { - const bytes = [0x48, 0x65, 0x6c, 0x6c, 0x6f]; - const enc = bytesToEncoded(bytes, 'base64'); - expect(enc).toBe('SGVsbG8='); - expect(encodedToBytes(enc, 'base64')).toEqual(bytes); - }); - - it('ascii round-trip for printable input', () => { - const bytes = [0x48, 0x69]; - expect(bytesToEncoded(bytes, 'ascii')).toBe('Hi'); - expect(encodedToBytes('Hi', 'ascii')).toEqual(bytes); - }); - - it('encodedToBytes returns null for invalid hex', () => { - expect(encodedToBytes('zz', 'hex')).toBeNull(); - expect(encodedToBytes('abc', 'hex')).toBeNull(); - }); - - it('encodedToBytes returns null for invalid base64', () => { - expect(encodedToBytes('!!!', 'base64')).toBeNull(); - }); - - it('encodedToBytes always accepts ascii', () => { - expect(encodedToBytes('ÿx', 'ascii')).toEqual([0xff, 0x78]); - }); - - it('encodedToBytes returns empty array for empty input in any encoding', () => { - expect(encodedToBytes('', 'hex')).toEqual([]); - expect(encodedToBytes('', 'base64')).toEqual([]); - expect(encodedToBytes('', 'ascii')).toEqual([]); - }); - }); - - describe('isBinaryEncoding', () => { - it('accepts valid values', () => { - expect(isBinaryEncoding('hex')).toBe(true); - expect(isBinaryEncoding('base64')).toBe(true); - expect(isBinaryEncoding('ascii')).toBe(true); - }); - - it('rejects unknown values', () => { - expect(isBinaryEncoding('utf8')).toBe(false); - expect(isBinaryEncoding(undefined)).toBe(false); - expect(isBinaryEncoding(null)).toBe(false); - expect(isBinaryEncoding(123)).toBe(false); - }); - }); - - describe('toBufferJson', () => { - it('wraps bytes in Buffer-JSON shape', () => { - expect(toBufferJson([1, 2])).toEqual({ type: 'Buffer', data: [1, 2] }); - }); - }); -}); diff --git a/frontend/src/app/lib/binary.ts b/frontend/src/app/lib/binary.ts deleted file mode 100644 index 7816b8d8f..000000000 --- a/frontend/src/app/lib/binary.ts +++ /dev/null @@ -1,119 +0,0 @@ -export type BinaryBufferJson = { type: 'Buffer'; data: number[] }; - -export type BinaryEncoding = 'hex' | 'base64' | 'ascii'; - -export const BINARY_ENCODINGS = ['hex', 'base64', 'ascii'] as const; - -export function isBinaryEncoding(value: unknown): value is BinaryEncoding { - return value === 'hex' || value === 'base64' || value === 'ascii'; -} - -export function parseBinaryValue(value: unknown): number[] { - if (value == null || value === '') return []; - if (value instanceof Uint8Array) return Array.from(value); - if (typeof value === 'string') return stringToBytes(value); - if (typeof value === 'object') { - const data = (value as { data?: unknown }).data; - if (Array.isArray(data)) return (data as number[]).slice(); - } - return []; -} - -export function stringToBytes(str: string): number[] { - const bytes: number[] = []; - for (let i = 0; i < str.length; i++) { - bytes.push(str.charCodeAt(i) & 0xff); - } - return bytes; -} - -export function hexStringToBytes(hex: string): number[] { - if (!hex) return []; - const normalized = hex.length % 2 === 0 ? hex : `0${hex}`; - const bytes: number[] = []; - for (let i = 0; i < normalized.length; i += 2) { - const byte = parseInt(normalized.substring(i, i + 2), 16); - if (Number.isNaN(byte)) return []; - bytes.push(byte); - } - return bytes; -} - -export function bytesToHex(bytes: number[]): string { - return bytes.map((b) => b.toString(16).padStart(2, '0')).join(''); -} - -const BASE64_CHUNK = 0x2000; - -export function bytesToBase64(bytes: number[]): string { - if (bytes.length === 0) return ''; - let binary = ''; - for (let i = 0; i < bytes.length; i += BASE64_CHUNK) { - const chunk = bytes.slice(i, i + BASE64_CHUNK); - binary += String.fromCharCode.apply(null, chunk); - } - return btoa(binary); -} - -const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; - -export function base64StringToBytes(str: string): number[] | null { - if (!str) return []; - if (str.length % 4 !== 0 || !BASE64_REGEX.test(str)) return null; - try { - const binary = atob(str); - const bytes: number[] = []; - for (let i = 0; i < binary.length; i++) { - bytes.push(binary.charCodeAt(i) & 0xff); - } - return bytes; - } catch { - return null; - } -} - -// Replacement character for non-printable ASCII bytes in display/encode. -const ASCII_REPLACEMENT = '.'; - -function isPrintableAscii(byte: number): boolean { - if (byte === 0x09 || byte === 0x0a || byte === 0x0d) return true; - return byte >= 0x20 && byte !== 0x7f && byte <= 0x7e; -} - -export function bytesToAscii(bytes: number[]): string { - let out = ''; - for (const b of bytes) { - out += isPrintableAscii(b) ? String.fromCharCode(b) : ASCII_REPLACEMENT; - } - return out; -} - -export function bytesToEncoded(bytes: number[], encoding: BinaryEncoding): string { - switch (encoding) { - case 'hex': - return bytesToHex(bytes); - case 'base64': - return bytesToBase64(bytes); - case 'ascii': - return bytesToAscii(bytes); - } -} - -const HEX_REGEX = /^[0-9A-Fa-f]*$/; - -export function encodedToBytes(str: string, encoding: BinaryEncoding): number[] | null { - if (!str) return []; - switch (encoding) { - case 'hex': - if (!HEX_REGEX.test(str) || str.length % 2 !== 0) return null; - return hexStringToBytes(str); - case 'base64': - return base64StringToBytes(str); - case 'ascii': - return stringToBytes(str); - } -} - -export function toBufferJson(bytes: number[]): BinaryBufferJson { - return { type: 'Buffer', data: bytes }; -} diff --git a/frontend/src/app/lib/cedar-monaco-language.ts b/frontend/src/app/lib/cedar-monaco-language.ts deleted file mode 100644 index af8ec6fb6..000000000 --- a/frontend/src/app/lib/cedar-monaco-language.ts +++ /dev/null @@ -1,49 +0,0 @@ -let registered = false; - -// biome-ignore lint/suspicious/noExplicitAny: Monaco instance type not available -export function registerCedarLanguage(monaco: any): void { - if (registered || !monaco) return; - registered = true; - - monaco.languages.register({ id: 'cedar' }); - - monaco.languages.setMonarchTokensProvider('cedar', { - keywords: ['permit', 'forbid', 'when', 'unless', 'if', 'then', 'else', 'in', 'has', 'like', 'is'], - clauses: ['principal', 'action', 'resource', 'context'], - operators: ['==', '!=', '<', '>', '<=', '>=', '&&', '||', '!'], - - tokenizer: { - root: [ - // Line comments - [/\/\/.*$/, 'comment'], - - // Strings - [/"[^"]*"/, 'string'], - - // Namespace paths (RocketAdmin::Action, etc.) - [/[A-Z]\w*(?:::[A-Z]\w*)+/, 'type.identifier'], - - // Keywords and clauses - [ - /[a-z_]\w*/, - { - cases: { - '@keywords': 'keyword', - '@clauses': 'variable', - '@default': 'identifier', - }, - }, - ], - - // Operators - [/[=!<>&|]+/, 'operator'], - - // Delimiters - [/[(){};,]/, 'delimiter'], - - // Whitespace - [/\s+/, 'white'], - ], - }, - }); -} diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts deleted file mode 100644 index 95ac3026d..000000000 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { AccessLevel, Permissions } from '../models/user'; - -export type PolicyActionResource = 'connection' | 'group' | 'table' | 'actionEvent' | 'dashboard' | 'panel'; - -export interface CedarPolicyItem { - action: string; - tableName?: string; - dashboardId?: string; -} - -export interface PolicyAction { - value: string; - resource?: PolicyActionResource; -} - -export interface PolicyActionGroup { - group: string; - actions: PolicyAction[]; -} - -export function permissionsToPolicyItems(permissions: Permissions): CedarPolicyItem[] { - const items: CedarPolicyItem[] = []; - - const connAccess = permissions.connection.accessLevel; - if (connAccess === AccessLevel.Edit) { - items.push({ action: 'connection:read' }); - items.push({ action: 'connection:edit' }); - } else if (connAccess === AccessLevel.Readonly) { - items.push({ action: 'connection:read' }); - } - - const groupAccess = permissions.group.accessLevel; - if (groupAccess === AccessLevel.Edit) { - items.push({ action: 'group:read' }); - items.push({ action: 'group:edit' }); - } else if (groupAccess === AccessLevel.Readonly) { - items.push({ action: 'group:read' }); - } - - // Check if all tables share the same permissions — collapse to wildcard '*' - const tables = permissions.tables; - if (tables.length > 0) { - const allRead = tables.every((t) => t.accessLevel.visibility); - const allAdd = tables.every((t) => t.accessLevel.add); - const allEdit = tables.every((t) => t.accessLevel.edit); - const allDelete = tables.every((t) => t.accessLevel.delete); - const anyTableAccess = allRead || allAdd || allEdit || allDelete; - - if (anyTableAccess && allRead && allAdd && allEdit && allDelete) { - items.push({ action: 'table:*', tableName: '*' }); - } else if (anyTableAccess && (allRead || allAdd || allEdit || allDelete)) { - // Some actions apply to all tables, others are per-table - if (allRead) items.push({ action: 'table:read', tableName: '*' }); - if (allAdd) items.push({ action: 'table:add', tableName: '*' }); - if (allEdit) items.push({ action: 'table:edit', tableName: '*' }); - if (allDelete) items.push({ action: 'table:delete', tableName: '*' }); - for (const table of tables) { - const access = table.accessLevel; - if (!allRead && access.visibility) items.push({ action: 'table:read', tableName: table.tableName }); - if (!allAdd && access.add) items.push({ action: 'table:add', tableName: table.tableName }); - if (!allEdit && access.edit) items.push({ action: 'table:edit', tableName: table.tableName }); - if (!allDelete && access.delete) items.push({ action: 'table:delete', tableName: table.tableName }); - } - } else { - for (const table of tables) { - const access = table.accessLevel; - if (access.visibility && access.add && access.edit && access.delete) { - items.push({ action: 'table:*', tableName: table.tableName }); - } else { - if (access.visibility) items.push({ action: 'table:read', tableName: table.tableName }); - if (access.add) items.push({ action: 'table:add', tableName: table.tableName }); - if (access.edit) items.push({ action: 'table:edit', tableName: table.tableName }); - if (access.delete) items.push({ action: 'table:delete', tableName: table.tableName }); - } - } - } - } - - return items; -} - -export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: string, groupId: string): string { - const policies: string[] = []; - - for (const item of items) { - if (item.action === '*') { - policies.push('permit(\n principal,\n action,\n resource\n);'); - return policies.join('\n\n'); - } - - const actionRef = buildActionRef(item.action); - let resource: string; - - if (item.action.startsWith('table:')) { - resource = buildResourceRef('Table', connectionId, item.tableName); - } else if (item.action === 'dashboard:create') { - resource = `resource == RocketAdmin::Connection::"${connectionId}"`; - } else if (item.action.startsWith('dashboard:')) { - resource = buildResourceRef('Dashboard', connectionId, item.dashboardId); - } else if (item.action.startsWith('group:')) { - resource = `resource == RocketAdmin::Group::"${groupId}"`; - } else { - resource = `resource == RocketAdmin::Connection::"${connectionId}"`; - } - - policies.push(`permit(\n principal,\n ${actionRef},\n ${resource}\n);`); - } - - return policies.join('\n\n'); -} - -const TABLE_ACTIONS = ['table:read', 'table:add', 'table:edit', 'table:delete']; -const DASHBOARD_ACTIONS = ['dashboard:read', 'dashboard:create', 'dashboard:edit', 'dashboard:delete']; - -function buildActionRef(action: string): string { - if (action === 'table:*') { - const list = TABLE_ACTIONS.map((a) => `RocketAdmin::Action::"${a}"`).join(', '); - return `action in [${list}]`; - } - if (action === 'dashboard:*') { - const list = DASHBOARD_ACTIONS.map((a) => `RocketAdmin::Action::"${a}"`).join(', '); - return `action in [${list}]`; - } - return `action == RocketAdmin::Action::"${action}"`; -} - -function buildResourceRef(type: string, connectionId: string, id: string | undefined): string { - if (id === '*') { - return `resource`; - } - return `resource == RocketAdmin::${type}::"${connectionId}/${id}"`; -} diff --git a/frontend/src/app/lib/cedar-policy-parser.ts b/frontend/src/app/lib/cedar-policy-parser.ts deleted file mode 100644 index d90dedea9..000000000 --- a/frontend/src/app/lib/cedar-policy-parser.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { AccessLevel, Permissions, TablePermission } from '../models/user'; -import { CedarPolicyItem } from './cedar-policy-items'; - -interface ParsedPermitStatement { - action: string | null; - actions: string[] | null; - resourceType: string | null; - resourceId: string | null; - isWildcard: boolean; -} - -export function parseCedarPolicy( - policyText: string, - connectionId: string, - groupId: string, - availableTables: TablePermission[], -): Permissions { - const permits = extractPermitStatements(policyText); - - const result: Permissions = { - connection: { connectionId, accessLevel: AccessLevel.None }, - group: { groupId, accessLevel: AccessLevel.None }, - tables: [], - }; - - const tableMap = new Map(); - let isFullAccess = false; - - for (const permit of permits) { - if (permit.isWildcard) { - result.connection.accessLevel = AccessLevel.Edit; - result.group.accessLevel = AccessLevel.Edit; - isFullAccess = true; - continue; - } - - if (!permit.action) continue; - - switch (permit.action) { - case 'connection:read': - if (result.connection.accessLevel === AccessLevel.None) { - result.connection.accessLevel = AccessLevel.Readonly; - } - break; - case 'connection:edit': - result.connection.accessLevel = AccessLevel.Edit; - break; - case 'group:read': - if (result.group.accessLevel === AccessLevel.None) { - result.group.accessLevel = AccessLevel.Readonly; - } - break; - case 'group:edit': - result.group.accessLevel = AccessLevel.Edit; - break; - case 'table:*': - case 'table:read': - case 'table:add': - case 'table:edit': - case 'table:delete': { - const tableName = extractResourceSuffix(permit.resourceId, connectionId); - if (!tableName) break; - const tableEntry = getOrCreateTableEntry(tableMap, tableName); - applyTableAction(tableEntry, permit.action); - break; - } - case 'dashboard:read': - case 'dashboard:create': - case 'dashboard:edit': - case 'dashboard:delete': - break; - } - } - - // Apply wildcard table permissions to all tables - const wildcardEntry = tableMap.get('*'); - - // Merge parsed results with full table list - result.tables = availableTables.map((table) => { - if (isFullAccess) { - return { - ...table, - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }; - } - const base = { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }; - if (wildcardEntry) { - base.visibility = base.visibility || wildcardEntry.accessLevel.visibility; - base.readonly = base.readonly || wildcardEntry.accessLevel.readonly; - base.add = base.add || wildcardEntry.accessLevel.add; - base.edit = base.edit || wildcardEntry.accessLevel.edit; - base.delete = base.delete || wildcardEntry.accessLevel.delete; - } - const parsed = tableMap.get(table.tableName); - if (parsed) { - base.visibility = base.visibility || parsed.accessLevel.visibility; - base.readonly = base.readonly || parsed.accessLevel.readonly; - base.add = base.add || parsed.accessLevel.add; - base.edit = base.edit || parsed.accessLevel.edit; - base.delete = base.delete || parsed.accessLevel.delete; - } - return { - ...table, - accessLevel: base, - }; - }); - - return result; -} - -export function parseCedarDashboardItems(policyText: string, connectionId: string): CedarPolicyItem[] { - const permits = extractPermitStatements(policyText); - const items: CedarPolicyItem[] = []; - - for (const permit of permits) { - if (!permit.action || !permit.action.startsWith('dashboard:')) continue; - if (permit.action === 'dashboard:create') { - items.push({ action: permit.action }); - } else { - const dashboardId = extractResourceSuffix(permit.resourceId, connectionId); - if (dashboardId) { - items.push({ action: permit.action, dashboardId }); - } - } - } - - return items; -} - -export function canRepresentAsForm(policyText: string): boolean { - if (!policyText?.trim()) return true; - - // Forbid statements are not supported in form mode - if (/\bforbid\s*\(/.test(policyText)) return false; - - // when/unless clauses are not supported in form mode - if (/\bwhen\s*\{/.test(policyText)) return false; - if (/\bunless\s*\{/.test(policyText)) return false; - - const permits = extractPermitStatements(policyText); - if (permits.length === 0) return false; - - const knownActions = new Set([ - 'connection:read', - 'connection:edit', - 'group:read', - 'group:edit', - 'table:*', - 'table:read', - 'table:add', - 'table:edit', - 'table:delete', - 'dashboard:*', - 'dashboard:read', - 'dashboard:create', - 'dashboard:edit', - 'dashboard:delete', - ]); - - for (const permit of permits) { - if (permit.isWildcard) continue; - if (!permit.action || !knownActions.has(permit.action)) return false; - } - - return true; -} - -function extractPermitStatements(policyText: string): ParsedPermitStatement[] { - const results: ParsedPermitStatement[] = []; - const permitKeyword = 'permit'; - let searchFrom = 0; - - while (searchFrom < policyText.length) { - const permitIndex = policyText.indexOf(permitKeyword, searchFrom); - if (permitIndex === -1) break; - - let i = permitIndex + permitKeyword.length; - while ( - i < policyText.length && - (policyText[i] === ' ' || policyText[i] === '\t' || policyText[i] === '\n' || policyText[i] === '\r') - ) { - i++; - } - - if (i >= policyText.length || policyText[i] !== '(') { - searchFrom = i; - continue; - } - - let depth = 1; - const bodyStart = i + 1; - i++; - while (i < policyText.length && depth > 0) { - if (policyText[i] === '(') depth++; - else if (policyText[i] === ')') depth--; - if (depth > 0) i++; - } - - if (depth !== 0) { - searchFrom = bodyStart; - continue; - } - - const body = policyText.slice(bodyStart, i); - let j = i + 1; - while ( - j < policyText.length && - (policyText[j] === ' ' || policyText[j] === '\t' || policyText[j] === '\n' || policyText[j] === '\r') - ) { - j++; - } - - if (j < policyText.length && policyText[j] === ';') { - results.push(parsePermitBody(body)); - searchFrom = j + 1; - } else { - searchFrom = i + 1; - } - } - - return results.flatMap(expandActionIn); -} - -function expandActionIn(stmt: ParsedPermitStatement): ParsedPermitStatement[] { - if (!stmt.actions || stmt.actions.length === 0) return [stmt]; - return stmt.actions.map((action) => ({ ...stmt, action, actions: null })); -} - -function parsePermitBody(body: string): ParsedPermitStatement { - const result: ParsedPermitStatement = { - action: null, - actions: null, - resourceType: null, - resourceId: null, - isWildcard: false, - }; - - const actionMatch = body.match(/action\s*(?:==|like)\s*RocketAdmin::Action::"([^"]+)"/); - if (actionMatch) { - result.action = actionMatch[1]; - } else { - const actionInMatch = body.match(/action\s+in\s*\[([^\]]+)\]/); - if (actionInMatch) { - result.actions = [...actionInMatch[1].matchAll(/RocketAdmin::Action::"([^"]+)"/g)].map((m) => m[1]); - } else { - const actionClause = body.match(/,\s*(action)\s*,/); - if (actionClause) { - result.isWildcard = true; - } - } - } - - const resourceMatch = body.match(/resource\s*(?:==|like)\s*(RocketAdmin::\w+)::"([^"]+)"/); - if (resourceMatch) { - result.resourceType = resourceMatch[1]; - result.resourceId = resourceMatch[2]; - } else { - const resourceClause = body.match(/,\s*(resource)\s*$/m); - if (resourceClause && !result.action) { - result.isWildcard = true; - } - } - - return result; -} - -function extractResourceSuffix(resourceId: string | null, connectionId: string): string | null { - if (!resourceId) return '*'; - const prefix = `${connectionId}/`; - if (resourceId.startsWith(prefix)) { - return resourceId.slice(prefix.length); - } - return resourceId; -} - -function getOrCreateTableEntry(map: Map, tableName: string): TablePermission { - let entry = map.get(tableName); - if (!entry) { - entry = { - tableName, - display_name: tableName, - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }; - map.set(tableName, entry); - } - return entry; -} - -function applyTableAction(entry: TablePermission, action: string): void { - switch (action) { - case 'table:*': - entry.accessLevel.visibility = true; - entry.accessLevel.readonly = true; - entry.accessLevel.add = true; - entry.accessLevel.edit = true; - entry.accessLevel.delete = true; - break; - case 'table:read': - entry.accessLevel.visibility = true; - entry.accessLevel.readonly = true; - break; - case 'table:add': - entry.accessLevel.add = true; - break; - case 'table:edit': - entry.accessLevel.edit = true; - break; - case 'table:delete': - entry.accessLevel.delete = true; - break; - } -} diff --git a/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts b/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts deleted file mode 100644 index 77dacb672..000000000 --- a/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { checkParsePolicySet, initSync } from '@cedar-policy/cedar-wasm/web'; -import { AccessLevel, Permissions, TablePermission } from '../models/user'; -import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from './cedar-policy-items'; -import { canRepresentAsForm, parseCedarDashboardItems, parseCedarPolicy } from './cedar-policy-parser'; - -const CONNECTION_ID = 'conn-abc-123'; -const GROUP_ID = 'group-xyz-789'; - -const AVAILABLE_TABLES: TablePermission[] = [ - { - tableName: 'users', - display_name: 'Users', - accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, - }, - { - tableName: 'orders', - display_name: 'Orders', - accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, - }, - { - tableName: 'products', - display_name: 'Products', - accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, - }, -]; - -function assertValidCedar(policyText: string, label: string) { - const result = checkParsePolicySet({ - staticPolicies: policyText, - templates: {}, - templateLinks: [], - }); - if (result.type !== 'success') { - throw new Error( - `${label}: invalid Cedar policy.\nErrors: ${JSON.stringify(result.errors)}\nPolicy:\n${policyText}`, - ); - } -} - -function roundTrip( - cedarText: string, - availableTables: TablePermission[] = AVAILABLE_TABLES, -): { permissions: Permissions; items: CedarPolicyItem[]; reserialized: string } { - const permissions = parseCedarPolicy(cedarText, CONNECTION_ID, GROUP_ID, availableTables); - const items = permissionsToPolicyItems(permissions); - const dashboardItems = parseCedarDashboardItems(cedarText, CONNECTION_ID); - const allItems = [...items, ...dashboardItems]; - const reserialized = policyItemsToCedarPolicy(allItems, CONNECTION_ID, GROUP_ID); - return { permissions, items: allItems, reserialized }; -} - -describe('Cedar policy deserialization => serialization round-trip', () => { - beforeAll(async () => { - const wasmBytes = await fetch('/assets/cedar-wasm/cedar_wasm_bg.wasm').then((r) => r.arrayBuffer()); - initSync({ module: new WebAssembly.Module(wasmBytes) }); - }); - - describe('connection edit policy', () => { - const connectionEditPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:edit",', - ' resource == RocketAdmin::Connection::"conn-abc-123"', - ');', - ].join('\n'); - - it('original policy should be valid Cedar', () => { - assertValidCedar(connectionEditPolicy, 'original'); - }); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(connectionEditPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should preserve connection edit access level', () => { - const { permissions } = roundTrip(connectionEditPolicy); - expect(permissions.connection.accessLevel).toBe(AccessLevel.Edit); - }); - - it('re-serialized policy should parse back to the same permissions', () => { - const { reserialized } = roundTrip(connectionEditPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(connectionEditPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.connection.accessLevel).toBe(original.connection.accessLevel); - expect(reparsed.group.accessLevel).toBe(original.group.accessLevel); - expect(reparsed.tables).toEqual(original.tables); - }); - }); - - describe('connection read-only policy', () => { - const connectionReadPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:read",', - ' resource == RocketAdmin::Connection::"conn-abc-123"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(connectionReadPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should preserve connection readonly access level', () => { - const { permissions } = roundTrip(connectionReadPolicy); - expect(permissions.connection.accessLevel).toBe(AccessLevel.Readonly); - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(connectionReadPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.connection.accessLevel).toBe(AccessLevel.Readonly); - }); - }); - - describe('full access (wildcard) policy', () => { - const fullAccessPolicy = ['permit(', ' principal,', ' action,', ' resource', ');'].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(fullAccessPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should parse as full access', () => { - const { permissions } = roundTrip(fullAccessPolicy); - expect(permissions.connection.accessLevel).toBe(AccessLevel.Edit); - expect(permissions.group.accessLevel).toBe(AccessLevel.Edit); - for (const table of permissions.tables) { - expect(table.accessLevel.visibility).toBe(true); - expect(table.accessLevel.add).toBe(true); - expect(table.accessLevel.edit).toBe(true); - expect(table.accessLevel.delete).toBe(true); - } - }); - - it('re-serialized policy should parse back to full access', () => { - const { reserialized } = roundTrip(fullAccessPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.connection.accessLevel).toBe(AccessLevel.Edit); - expect(reparsed.group.accessLevel).toBe(AccessLevel.Edit); - }); - }); - - describe('group permissions policy', () => { - const groupPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"group:read",', - ' resource == RocketAdmin::Group::"group-xyz-789"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"group:edit",', - ' resource == RocketAdmin::Group::"group-xyz-789"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(groupPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should preserve group edit access level', () => { - const { permissions } = roundTrip(groupPolicy); - expect(permissions.group.accessLevel).toBe(AccessLevel.Edit); - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(groupPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.group.accessLevel).toBe(AccessLevel.Edit); - }); - }); - - describe('single table read policy', () => { - const tableReadPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource == RocketAdmin::Table::"conn-abc-123/users"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(tableReadPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should parse users table as visible and readonly', () => { - const { permissions } = roundTrip(tableReadPolicy); - const usersTable = permissions.tables.find((t) => t.tableName === 'users'); - expect(usersTable).toBeTruthy(); - expect(usersTable!.accessLevel.visibility).toBe(true); - expect(usersTable!.accessLevel.readonly).toBe(true); - expect(usersTable!.accessLevel.add).toBe(false); - expect(usersTable!.accessLevel.edit).toBe(false); - expect(usersTable!.accessLevel.delete).toBe(false); - }); - - it('other tables should have no access', () => { - const { permissions } = roundTrip(tableReadPolicy); - const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); - expect(ordersTable!.accessLevel.visibility).toBe(false); - expect(ordersTable!.accessLevel.readonly).toBe(false); - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(tableReadPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(tableReadPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.tables).toEqual(original.tables); - }); - }); - - describe('table full access (action in [...]) policy', () => { - const tableFullAccessPolicy = [ - 'permit(', - ' principal,', - ' action in [RocketAdmin::Action::"table:read", RocketAdmin::Action::"table:add", RocketAdmin::Action::"table:edit", RocketAdmin::Action::"table:delete"],', - ' resource == RocketAdmin::Table::"conn-abc-123/orders"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(tableFullAccessPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should parse orders table with full access', () => { - const { permissions } = roundTrip(tableFullAccessPolicy); - const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); - expect(ordersTable!.accessLevel.visibility).toBe(true); - expect(ordersTable!.accessLevel.add).toBe(true); - expect(ordersTable!.accessLevel.edit).toBe(true); - expect(ordersTable!.accessLevel.delete).toBe(true); - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(tableFullAccessPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(tableFullAccessPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.tables).toEqual(original.tables); - }); - }); - - describe('wildcard table permissions (all tables)', () => { - const wildcardTablePolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(wildcardTablePolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should apply read to all tables', () => { - const { permissions } = roundTrip(wildcardTablePolicy); - for (const table of permissions.tables) { - expect(table.accessLevel.visibility).toBe(true); - expect(table.accessLevel.readonly).toBe(true); - } - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(wildcardTablePolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(wildcardTablePolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.tables).toEqual(original.tables); - }); - }); - - describe('mixed policy (connection + group + tables)', () => { - const mixedPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:read",', - ' resource == RocketAdmin::Connection::"conn-abc-123"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"group:read",', - ' resource == RocketAdmin::Group::"group-xyz-789"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource == RocketAdmin::Table::"conn-abc-123/users"', - ');', - '', - 'permit(', - ' principal,', - ' action in [RocketAdmin::Action::"table:read", RocketAdmin::Action::"table:edit"],', - ' resource == RocketAdmin::Table::"conn-abc-123/orders"', - ');', - ].join('\n'); - - it('original policy should be valid Cedar', () => { - assertValidCedar(mixedPolicy, 'original'); - }); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(mixedPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should preserve all access levels', () => { - const { permissions } = roundTrip(mixedPolicy); - expect(permissions.connection.accessLevel).toBe(AccessLevel.Readonly); - expect(permissions.group.accessLevel).toBe(AccessLevel.Readonly); - - const usersTable = permissions.tables.find((t) => t.tableName === 'users'); - expect(usersTable!.accessLevel.visibility).toBe(true); - expect(usersTable!.accessLevel.readonly).toBe(true); - expect(usersTable!.accessLevel.edit).toBe(false); - - const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); - expect(ordersTable!.accessLevel.visibility).toBe(true); - expect(ordersTable!.accessLevel.edit).toBe(true); - }); - - it('re-serialized policy should parse back to the same permissions', () => { - const { reserialized } = roundTrip(mixedPolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(mixedPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.connection.accessLevel).toBe(original.connection.accessLevel); - expect(reparsed.group.accessLevel).toBe(original.group.accessLevel); - expect(reparsed.tables).toEqual(original.tables); - }); - - it('should be representable as form', () => { - expect(canRepresentAsForm(mixedPolicy)).toBe(true); - }); - }); - - describe('dashboard permissions policy', () => { - const dashboardPolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"dashboard:read",', - ' resource == RocketAdmin::Dashboard::"conn-abc-123/dash-001"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"dashboard:edit",', - ' resource == RocketAdmin::Dashboard::"conn-abc-123/dash-001"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(dashboardPolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should parse dashboard items correctly', () => { - const dashboardItems = parseCedarDashboardItems(dashboardPolicy, CONNECTION_ID); - expect(dashboardItems.length).toBe(2); - expect(dashboardItems.some((item) => item.action === 'dashboard:read')).toBe(true); - expect(dashboardItems.some((item) => item.action === 'dashboard:edit')).toBe(true); - expect(dashboardItems.every((item) => item.dashboardId === 'dash-001')).toBe(true); - }); - - it('re-serialized policy should parse back to the same dashboard items', () => { - const { reserialized } = roundTrip(dashboardPolicy); - const originalItems = parseCedarDashboardItems(dashboardPolicy, CONNECTION_ID); - const reparsedItems = parseCedarDashboardItems(reserialized, CONNECTION_ID); - expect(reparsedItems).toEqual(originalItems); - }); - }); - - describe('multiple table-specific permissions', () => { - const multiTablePolicy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource == RocketAdmin::Table::"conn-abc-123/users"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:add",', - ' resource == RocketAdmin::Table::"conn-abc-123/users"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource == RocketAdmin::Table::"conn-abc-123/orders"', - ');', - '', - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:delete",', - ' resource == RocketAdmin::Table::"conn-abc-123/orders"', - ');', - ].join('\n'); - - it('should round-trip to valid Cedar', () => { - const { reserialized } = roundTrip(multiTablePolicy); - assertValidCedar(reserialized, 'reserialized'); - }); - - it('should parse per-table permissions correctly', () => { - const { permissions } = roundTrip(multiTablePolicy); - - const usersTable = permissions.tables.find((t) => t.tableName === 'users'); - expect(usersTable!.accessLevel.visibility).toBe(true); - expect(usersTable!.accessLevel.add).toBe(true); - expect(usersTable!.accessLevel.edit).toBe(false); - - const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); - expect(ordersTable!.accessLevel.visibility).toBe(true); - expect(ordersTable!.accessLevel.delete).toBe(true); - expect(ordersTable!.accessLevel.add).toBe(false); - - const productsTable = permissions.tables.find((t) => t.tableName === 'products'); - expect(productsTable!.accessLevel.visibility).toBe(false); - }); - - it('re-serialized policy should parse back identically', () => { - const { reserialized } = roundTrip(multiTablePolicy); - const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - const original = parseCedarPolicy(multiTablePolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); - expect(reparsed.tables).toEqual(original.tables); - }); - }); - - describe('canRepresentAsForm validation', () => { - it('forbid statements should not be representable', () => { - const policy = 'forbid(principal, action, resource);'; - expect(canRepresentAsForm(policy)).toBe(false); - }); - - it('when clauses should not be representable', () => { - const policy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource', - ') when { principal.isAdmin };', - ].join('\n'); - expect(canRepresentAsForm(policy)).toBe(false); - }); - - it('unless clauses should not be representable', () => { - const policy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"table:read",', - ' resource', - ') unless { principal.isGuest };', - ].join('\n'); - expect(canRepresentAsForm(policy)).toBe(false); - }); - - it('empty policy should be representable', () => { - expect(canRepresentAsForm('')).toBe(true); - }); - - it('standard permit policies should be representable', () => { - const policy = [ - 'permit(', - ' principal,', - ' action == RocketAdmin::Action::"connection:read",', - ' resource == RocketAdmin::Connection::"conn-abc-123"', - ');', - ].join('\n'); - expect(canRepresentAsForm(policy)).toBe(true); - }); - }); - - describe('cedar-wasm validation of all generated policies', () => { - it('connection:read item generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'connection:read' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'connection:read'); - }); - - it('connection:edit item generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'connection:edit' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'connection:edit'); - }); - - it('group:read item generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'group:read' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'group:read'); - }); - - it('group:edit item generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'group:edit' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'group:edit'); - }); - - it('table:read with specific table generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'table:read', tableName: 'users' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'table:read specific'); - }); - - it('table:* with specific table generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'table:*', tableName: 'users' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'table:* specific'); - }); - - it('table:read with wildcard generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'table:read', tableName: '*' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'table:read wildcard'); - }); - - it('dashboard:read with specific dashboard generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'dashboard:read', dashboardId: 'dash-001' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'dashboard:read specific'); - }); - - it('dashboard:* with specific dashboard generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: 'dashboard:*', dashboardId: 'dash-001' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'dashboard:* specific'); - }); - - it('full access (*) generates valid Cedar', () => { - const items: CedarPolicyItem[] = [{ action: '*' }]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'full access'); - }); - - it('mixed items generate valid Cedar', () => { - const items: CedarPolicyItem[] = [ - { action: 'connection:read' }, - { action: 'group:read' }, - { action: 'group:edit' }, - { action: 'table:read', tableName: 'users' }, - { action: 'table:add', tableName: 'users' }, - { action: 'table:read', tableName: '*' }, - { action: 'dashboard:read', dashboardId: 'dash-001' }, - ]; - const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); - assertValidCedar(policy, 'mixed items'); - }); - }); -}); diff --git a/frontend/src/app/lib/chart-config.helper.ts b/frontend/src/app/lib/chart-config.helper.ts deleted file mode 100644 index df5f95b10..000000000 --- a/frontend/src/app/lib/chart-config.helper.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { ChartConfiguration, ChartData, ChartType as ChartJsType, Plugin } from 'chart.js'; -import convert from 'convert'; -import { - ChartAxisConfig, - ChartNumberFormatConfig, - ChartSeriesConfig, - ChartType, - ChartUnitConfig, - ChartWidgetOptions, -} from '../models/saved-query'; - -export const DEFAULT_COLOR_PALETTE = [ - 'rgba(99, 102, 241, 0.8)', - 'rgba(168, 85, 247, 0.8)', - 'rgba(236, 72, 153, 0.8)', - 'rgba(244, 63, 94, 0.8)', - 'rgba(251, 146, 60, 0.8)', - 'rgba(234, 179, 8, 0.8)', - 'rgba(34, 197, 94, 0.8)', - 'rgba(6, 182, 212, 0.8)', - 'rgba(59, 130, 246, 0.8)', - 'rgba(139, 92, 246, 0.8)', -]; - -export function formatChartValue( - value: number, - units?: ChartUnitConfig, - numberFormat?: ChartNumberFormatConfig, -): string { - // Smart unit conversion using the `convert` package - if (units?.convert_unit) { - try { - const best = convert(value, units.convert_unit as Parameters[1]).to('best'); - const quantity = parseFloat(best.quantity.toFixed(2)); - return `${quantity} ${best.unit}`; - } catch { - // Fall through to manual formatting if unit is invalid - } - } - - let formatted: string; - - if (numberFormat?.compact) { - formatted = compactNumber(value); - } else { - formatted = new Intl.NumberFormat(undefined, { - minimumFractionDigits: numberFormat?.decimal_places, - maximumFractionDigits: numberFormat?.decimal_places, - useGrouping: numberFormat?.thousands_separator ?? true, - }).format(value); - } - - if (!units?.text) return formatted; - return units.position === 'prefix' ? `${units.text}${formatted}` : `${formatted}${units.text}`; -} - -function compactNumber(value: number): string { - const abs = Math.abs(value); - if (abs >= 1e9) return (value / 1e9).toFixed(1).replace(/\.0$/, '') + 'B'; - if (abs >= 1e6) return (value / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; - if (abs >= 1e3) return (value / 1e3).toFixed(1).replace(/\.0$/, '') + 'K'; - return String(value); -} - -function solidColor(rgba: string): string { - return rgba.replace('0.8)', '1)'); -} - -function parseDate(value: unknown): Date | null { - if (!value) return null; - try { - const date = new Date(value as string | number | Date); - return isNaN(date.getTime()) ? null : date; - } catch { - return null; - } -} - -function extractNumericValue(val: unknown): number { - return typeof val === 'number' ? val : parseFloat(String(val)) || 0; -} - -function sortData( - data: Record[], - sortBy: string, - labelColumn: string, - valueColumn?: string, -): Record[] { - const sorted = [...data]; - switch (sortBy) { - case 'label_asc': - sorted.sort((a, b) => String(a[labelColumn] ?? '').localeCompare(String(b[labelColumn] ?? ''))); - break; - case 'label_desc': - sorted.sort((a, b) => String(b[labelColumn] ?? '').localeCompare(String(a[labelColumn] ?? ''))); - break; - case 'value_asc': - if (valueColumn) { - sorted.sort((a, b) => extractNumericValue(a[valueColumn]) - extractNumericValue(b[valueColumn])); - } - break; - case 'value_desc': - if (valueColumn) { - sorted.sort((a, b) => extractNumericValue(b[valueColumn]) - extractNumericValue(a[valueColumn])); - } - break; - } - return sorted; -} - -function buildPivotedChartData( - rawData: Record[], - options: ChartWidgetOptions, -): ChartData | null { - const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE; - const labelColumn = options.label_column; - const seriesColumn = options.series_column!; - const valueColumn = options.value_column!; - const labelType = options.label_type ?? 'values'; - const useTimeScale = labelType === 'datetime'; - - // Pre-process data: sort and limit - let processedData = [...rawData]; - if (options.sort_by && options.sort_by !== 'none') { - processedData = sortData(processedData, options.sort_by, labelColumn, valueColumn); - } - if (options.limit) { - processedData = processedData.slice(0, options.limit); - } - - // Extract unique labels preserving first-appearance order - const labelSet = new Set(); - const seriesSet = new Set(); - for (const row of processedData) { - labelSet.add(String(row[labelColumn] ?? '')); - seriesSet.add(String(row[seriesColumn] ?? '')); - } - const labels = Array.from(labelSet); - const seriesValues = Array.from(seriesSet); - - // Build lookup: label -> seriesValue -> sum - const lookup = new Map>(); - for (const row of processedData) { - const label = String(row[labelColumn] ?? ''); - const series = String(row[seriesColumn] ?? ''); - const value = extractNumericValue(row[valueColumn]); - if (!lookup.has(label)) lookup.set(label, new Map()); - const inner = lookup.get(label)!; - inner.set(series, (inner.get(series) ?? 0) + value); - } - - if (useTimeScale) { - const datasets = seriesValues.map((sv, i) => { - const color = palette[i % palette.length]; - const dataPoints = labels - .map((label) => { - const date = parseDate(label); - if (!date) return null; - return { - x: date.getTime(), - y: lookup.get(label)?.get(sv) ?? 0, - }; - }) - .filter((p): p is { x: number; y: number } => p !== null) - .sort((a, b) => a.x - b.x); - - return { - label: sv, - data: dataPoints, - backgroundColor: color, - borderColor: solidColor(color), - borderWidth: 1, - fill: false, - tension: 0, - }; - }); - return { datasets }; - } - - // Categorical scale - const datasets = seriesValues.map((sv, i) => { - const color = palette[i % palette.length]; - const values = labels.map((label) => lookup.get(label)?.get(sv) ?? 0); - return { - label: sv, - data: values, - backgroundColor: color, - borderColor: solidColor(color), - borderWidth: 1, - }; - }); - - return { labels, datasets }; -} - -export function buildChartData( - rawData: Record[], - chartType: ChartType, - options: ChartWidgetOptions, -): ChartData | null { - if (!rawData.length) return null; - - const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); - - // Pivot mode: series_column is set with a value_column (not for pie types) - if (options.series_column && options.value_column && !isPieType) { - return buildPivotedChartData(rawData, options); - } - - const palette = options.color_palette ?? DEFAULT_COLOR_PALETTE; - const labelType = options.label_type ?? 'values'; - const useTimeScale = labelType === 'datetime' && !isPieType; - const labelColumn = options.label_column; - - if (!labelColumn) return null; - - // Resolve series - const seriesConfigs: ChartSeriesConfig[] = options.series?.length - ? options.series - : options.value_column - ? [{ value_column: options.value_column }] - : []; - - if (!seriesConfigs.length) return null; - - // Pre-process data: sort and limit - let processedData = [...rawData]; - if (options.sort_by && options.sort_by !== 'none') { - processedData = sortData(processedData, options.sort_by, labelColumn, seriesConfigs[0]?.value_column); - } - if (options.limit) { - processedData = processedData.slice(0, options.limit); - } - - if (useTimeScale) { - const datasets = seriesConfigs.map((s, i) => { - const color = s.color ?? palette[i % palette.length]; - const dataPoints = processedData - .map((row) => { - const date = parseDate(row[labelColumn]); - if (!date) return null; - return { - x: date.getTime(), - y: extractNumericValue(row[s.value_column]), - }; - }) - .filter((point): point is { x: number; y: number } => point !== null) - .sort((a, b) => a.x - b.x); - - return { - label: s.label ?? s.value_column, - data: dataPoints, - backgroundColor: color, - borderColor: solidColor(color), - borderWidth: 1, - fill: s.fill ?? false, - tension: s.tension ?? 0, - pointStyle: - s.point_style === 'none' - ? ('circle' as const) - : ((s.point_style ?? 'circle') as 'circle' | 'rect' | 'triangle' | 'cross'), - pointRadius: s.point_style === 'none' ? 0 : undefined, - spanGaps: false, - type: s.type as ChartJsType | undefined, - }; - }); - - return { datasets }; - } - - // Categorical scale - const labels = processedData.map((row) => String(row[labelColumn] ?? '')); - - if (isPieType) { - // Pie/doughnut/polarArea: single dataset with per-slice colors - const s = seriesConfigs[0]; - const values = processedData.map((row) => extractNumericValue(row[s.value_column])); - const sliceColors = s.colors ?? palette.slice(0, values.length); - const borderColors = sliceColors.map((c) => solidColor(c)); - - // If palette is shorter than values, cycle - const bgColors = values.map((_, i) => sliceColors[i % sliceColors.length]); - const bdColors = values.map((_, i) => borderColors[i % borderColors.length]); - - return { - labels, - datasets: [ - { - label: s.label ?? s.value_column, - data: values, - backgroundColor: bgColors, - borderColor: bdColors, - borderWidth: 1, - }, - ], - }; - } - - // Bar/line: multi-series - const datasets = seriesConfigs.map((s, i) => { - const color = s.color ?? palette[i % palette.length]; - const values = processedData.map((row) => extractNumericValue(row[s.value_column])); - - return { - label: s.label ?? s.value_column, - data: values, - backgroundColor: color, - borderColor: solidColor(color), - borderWidth: 1, - fill: s.fill ?? false, - tension: s.tension ?? 0, - pointStyle: - s.point_style === 'none' - ? ('circle' as const) - : ((s.point_style ?? 'circle') as 'circle' | 'rect' | 'triangle' | 'cross'), - pointRadius: s.point_style === 'none' ? 0 : undefined, - type: s.type as ChartJsType | undefined, - }; - }); - - return { labels, datasets }; -} - -function buildAxisConfig( - axisConfig: ChartAxisConfig | undefined, - defaults?: { beginAtZero?: boolean }, -): Record { - const config: Record = {}; - - if (axisConfig?.title) { - config['title'] = { display: true, text: axisConfig.title }; - } - if (axisConfig?.min !== undefined) { - config['min'] = axisConfig.min; - } - if (axisConfig?.max !== undefined) { - config['max'] = axisConfig.max; - } - - const beginAtZero = axisConfig?.begin_at_zero ?? defaults?.beginAtZero; - if (beginAtZero !== undefined) { - config['beginAtZero'] = beginAtZero; - } - - if (axisConfig?.scale_type === 'logarithmic') { - config['type'] = 'logarithmic'; - } - - return config; -} - -export function buildChartOptions(chartType: ChartType, options: ChartWidgetOptions): ChartConfiguration['options'] { - const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); - const labelType = options.label_type ?? 'values'; - const useTimeScale = labelType === 'datetime' && !isPieType; - - const seriesConfigs: ChartSeriesConfig[] = options.series?.length - ? options.series - : options.value_column - ? [{ value_column: options.value_column }] - : []; - - const globalUnits = options.units; - const globalFormat = options.number_format; - - const chartOptions: ChartConfiguration['options'] = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: options.legend?.show ?? true, - position: options.legend?.position ?? 'top', - }, - tooltip: { - callbacks: { - label: (context) => { - const seriesIndex = context.datasetIndex ?? 0; - const seriesConfig = options.series_column ? undefined : seriesConfigs[seriesIndex]; - const units = seriesConfig?.units ?? globalUnits; - const numFormat = seriesConfig?.number_format ?? globalFormat; - const raw = context.parsed?.y ?? context.parsed ?? context.raw; - const value = - typeof raw === 'number' - ? raw - : typeof raw === 'object' && raw !== null - ? ((raw as { y?: number }).y ?? 0) - : 0; - const label = context.dataset.label ?? ''; - const formatted = formatChartValue(value, units, numFormat); - return `${label}: ${formatted}`; - }, - }, - }, - }, - }; - - if (!isPieType) { - (chartOptions as Record)['indexAxis'] = options.horizontal ? 'y' : 'x'; - } - - // Scales for non-pie types - if (!isPieType) { - const scales: Record = {}; - - if (useTimeScale) { - scales['x'] = { - type: 'time', - time: { - tooltipFormat: 'MMM d, yyyy', - displayFormats: { - day: 'MMM d', - week: 'MMM d', - month: 'MMM yyyy', - year: 'yyyy', - }, - }, - ...buildAxisConfig(options.x_axis), - ...(options.stacked ? { stacked: true } : {}), - }; - } else { - const xConfig = buildAxisConfig(options.x_axis); - scales['x'] = { - ...xConfig, - ...(options.stacked ? { stacked: true } : {}), - }; - } - - const yConfig = buildAxisConfig(options.y_axis, { beginAtZero: true }); - scales['y'] = { - ...yConfig, - ...(options.stacked ? { stacked: true } : {}), - }; - - // Tick formatting for value axis - const tickAxis = options.horizontal ? 'x' : 'y'; - if (globalUnits || globalFormat) { - const axisObj = scales[tickAxis] as Record; - axisObj['ticks'] = { - callback: (tickValue: string | number) => { - const value = typeof tickValue === 'number' ? tickValue : parseFloat(tickValue); - return formatChartValue(value, globalUnits, globalFormat); - }, - }; - } - - (chartOptions as Record)['scales'] = scales; - } - - return chartOptions; -} - -export function buildDataLabelsPlugin(options: ChartWidgetOptions): Plugin { - const globalUnits = options.units; - const globalFormat = options.number_format; - - const seriesConfigs: ChartSeriesConfig[] = options.series?.length - ? options.series - : options.value_column - ? [{ value_column: options.value_column }] - : []; - - return { - id: 'customDataLabels', - afterDatasetsDraw(chart) { - if (!options.show_data_labels) return; - - const { ctx } = chart; - ctx.save(); - ctx.font = '11px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - - chart.data.datasets.forEach((dataset, datasetIndex) => { - const meta = chart.getDatasetMeta(datasetIndex); - const seriesConfig = options.series_column ? undefined : seriesConfigs[datasetIndex]; - const units = seriesConfig?.units ?? globalUnits; - const numFormat = seriesConfig?.number_format ?? globalFormat; - - meta.data.forEach((element, index) => { - const raw = dataset.data[index]; - let value: number; - if (typeof raw === 'number') { - value = raw; - } else if (raw && typeof raw === 'object' && 'y' in raw) { - value = (raw as { y: number }).y; - } else { - return; - } - - const formatted = formatChartValue(value, units, numFormat); - const { x, y } = element.tooltipPosition(false); - ctx.fillStyle = '#666'; - ctx.fillText(formatted, x, y - 5); - }); - }); - - ctx.restore(); - }, - }; -} - -export function getMappedChartType(chartType: ChartType, options: ChartWidgetOptions): ChartJsType { - // For mixed charts, the base type is used but individual datasets may override - const hasMixedTypes = options.series?.some((s) => s.type) ?? false; - if (hasMixedTypes) { - // Use 'bar' as base for mixed charts - individual series override via dataset.type - return 'bar' as ChartJsType; - } - return chartType as ChartJsType; -} diff --git a/frontend/src/app/lib/format-field-value.ts b/frontend/src/app/lib/format-field-value.ts deleted file mode 100644 index 127485017..000000000 --- a/frontend/src/app/lib/format-field-value.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { format } from 'date-fns'; - -export function formatFieldValue(value, type) { - const dateTimeTypes = [ - 'timestamp without time zone', - 'timestamp with time zone', - 'abstime', - 'realtime', - 'datetime', - 'timestamp', - ]; - - if (value && type === 'time') { - return value; - } else if (value && type === 'date') { - const datetimeValue = new Date(value); - return format(datetimeValue, 'P'); - } else if (value && dateTimeTypes.includes(type)) { - const datetimeValue = new Date(value); - return format(datetimeValue, 'P p'); - } else if (type === 'boolean') { - if (value || value === 1) return true; - else if (value === false || value === 0) return false; - else return '—'; - } else if (type === 'json' || type === 'jsonb' || type === 'object' || type === 'array' || type === 'interval') { - return JSON.stringify(value); - } - - return value; -} diff --git a/frontend/src/app/lib/normalize.spec.ts b/frontend/src/app/lib/normalize.spec.ts deleted file mode 100644 index 1e5a1a642..000000000 --- a/frontend/src/app/lib/normalize.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { normalizeFieldName, normalizeTableName } from './normalize'; - -describe('Normalize function', () => { - it('should modify name', () => { - const normalizedName = normalizeTableName('TABLE_USER'); - expect(normalizedName).toEqual('Table Users'); - }); - - it('should pluralise name', () => { - const normalizedName = normalizeTableName('USER'); - expect(normalizedName).toEqual('Users'); - }); - - it('normalize camel case and capitalize', () => { - const normalizedName = normalizeFieldName('affiliateStripeNumber'); - expect(normalizedName).toEqual('Affiliate stripe number'); - }); - - it('normalize camel case, capitalize and uppercase acronyms', () => { - const normalizedName = normalizeFieldName('affiliateStripeId'); - expect(normalizedName).toEqual('Affiliate stripe ID'); - }); -}); diff --git a/frontend/src/app/lib/normalize.ts b/frontend/src/app/lib/normalize.ts deleted file mode 100644 index e1d5c9f14..000000000 --- a/frontend/src/app/lib/normalize.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { camelCase, capitalize, startCase } from 'lodash-es'; -import pluralize from 'pluralize'; -import acronyms from '../consts/acronyms'; - -export function normalizeTableName(tableName: string) { - return pluralize(startCase(camelCase(tableName))); -} - -export function normalizeFieldName(fieldName: string) { - const setOfAcronyms = new Set(acronyms); - const setOfWords = capitalize(startCase(camelCase(fieldName))).split(' '); - - for (let [index, word] of setOfWords.entries()) { - const upperCasedWord = word.toUpperCase(); - if (setOfAcronyms.has(upperCasedWord)) { - setOfWords[index] = upperCasedWord; - } - } - return setOfWords.join(' '); -} diff --git a/frontend/src/app/lib/parse-filter-params.spec.ts b/frontend/src/app/lib/parse-filter-params.spec.ts deleted file mode 100644 index b041f8436..000000000 --- a/frontend/src/app/lib/parse-filter-params.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getComparatorsFromUrl, getFiltersFromUrl } from './parse-filter-params'; - -const queryParams = { - first_name: { - contains: 'sh', - }, - age: { - gt: 25, - }, - city: { - startswith: 'A', - }, -}; - -describe('Parse filter params', () => { - it('should return filters from queryParams', () => { - const filters = getFiltersFromUrl(queryParams); - - expect(filters).toEqual({ - first_name: 'sh', - age: 25, - city: 'A', - }); - }); - - it('should return comparators from queryParams', () => { - const comparators = getComparatorsFromUrl(queryParams); - - expect(comparators).toEqual({ - first_name: 'contains', - age: 'gt', - city: 'startswith', - }); - }); -}); diff --git a/frontend/src/app/lib/parse-filter-params.ts b/frontend/src/app/lib/parse-filter-params.ts deleted file mode 100644 index 453ae579e..000000000 --- a/frontend/src/app/lib/parse-filter-params.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function getFiltersFromUrl(queryParams) { - let filters = {}; - for (const key in queryParams) { - filters[key] = Object.values(queryParams[key])[0]; - } - return filters; -} - -export function getComparatorsFromUrl(queryParams) { - let comparators = {}; - for (const key in queryParams) { - comparators[key] = Object.keys(queryParams[key])[0]; - } - return comparators; -} diff --git a/frontend/src/app/lib/permission-display.spec.ts b/frontend/src/app/lib/permission-display.spec.ts deleted file mode 100644 index a4c14a78d..000000000 --- a/frontend/src/app/lib/permission-display.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { actionIcon, actionLabel, actionShortLabel, groupNameForAction } from './permission-display'; - -describe('permission-display', () => { - describe('actionLabel', () => { - it('formats simple verbs', () => { - expect(actionLabel('connection:read')).toBe('Connection read'); - expect(actionLabel('connection:edit')).toBe('Connection edit'); - expect(actionLabel('table:add')).toBe('Table add'); - expect(actionLabel('table:delete')).toBe('Table delete'); - }); - - it('preserves acronyms in hyphenated verbs', () => { - expect(actionLabel('table:ai-request')).toBe('Table AI request'); - }); - - it('formats camelCase prefixes', () => { - expect(actionLabel('actionEvent:trigger')).toBe('Action event trigger'); - }); - - it('formats wildcards', () => { - expect(actionLabel('*')).toBe('Full access (all permissions)'); - expect(actionLabel('table:*')).toBe('Full table access'); - expect(actionLabel('dashboard:*')).toBe('Full dashboard access'); - expect(actionLabel('panel:*')).toBe('Full panel access'); - }); - }); - - describe('actionShortLabel', () => { - it('returns verb-only short labels', () => { - expect(actionShortLabel('connection:read')).toBe('Read'); - expect(actionShortLabel('table:edit')).toBe('Edit'); - expect(actionShortLabel('actionEvent:trigger')).toBe('Trigger'); - expect(actionShortLabel('table:ai-request')).toBe('AI request'); - }); - - it('returns "Full access" for wildcards', () => { - expect(actionShortLabel('*')).toBe('Full access'); - expect(actionShortLabel('table:*')).toBe('Full access'); - expect(actionShortLabel('panel:*')).toBe('Full access'); - }); - }); - - describe('groupNameForAction', () => { - it('maps known prefixes', () => { - expect(groupNameForAction('connection:read')).toBe('Connection'); - expect(groupNameForAction('group:edit')).toBe('Group'); - expect(groupNameForAction('table:read')).toBe('Table'); - expect(groupNameForAction('actionEvent:trigger')).toBe('ActionEvent'); - expect(groupNameForAction('dashboard:read')).toBe('Dashboard'); - expect(groupNameForAction('panel:read')).toBe('Panel'); - }); - - it('falls back to capitalized prefix for unknown actions', () => { - expect(groupNameForAction('foo:bar')).toBe('Foo'); - }); - }); - - describe('actionIcon', () => { - it('maps known verbs', () => { - expect(actionIcon('connection:read')).toBe('visibility'); - expect(actionIcon('table:edit')).toBe('edit'); - expect(actionIcon('table:add')).toBe('add_circle'); - expect(actionIcon('dashboard:create')).toBe('add_circle'); - expect(actionIcon('table:delete')).toBe('delete'); - expect(actionIcon('actionEvent:trigger')).toBe('play_arrow'); - expect(actionIcon('connection:diagram')).toBe('schema'); - expect(actionIcon('table:ai-request')).toBe('auto_awesome'); - }); - - it('returns shield for wildcards', () => { - expect(actionIcon('*')).toBe('shield'); - expect(actionIcon('table:*')).toBe('shield'); - expect(actionIcon('dashboard:*')).toBe('shield'); - }); - - it('falls back for unknown verbs', () => { - expect(actionIcon('foo:bar')).toBe('help_outline'); - }); - }); -}); diff --git a/frontend/src/app/lib/permission-display.ts b/frontend/src/app/lib/permission-display.ts deleted file mode 100644 index 8d43b5cf7..000000000 --- a/frontend/src/app/lib/permission-display.ts +++ /dev/null @@ -1,80 +0,0 @@ -const PREFIX_TO_LABEL: Record = { - connection: 'Connection', - group: 'Group', - table: 'Table', - actionEvent: 'Action event', - dashboard: 'Dashboard', - panel: 'Panel', -}; - -const PREFIX_TO_GROUP_NAME: Record = { - connection: 'Connection', - group: 'Group', - table: 'Table', - actionEvent: 'ActionEvent', - dashboard: 'Dashboard', - panel: 'Panel', -}; - -export const PERMISSION_GROUP_ORDER = ['Connection', 'Group', 'Table', 'ActionEvent', 'Dashboard', 'Panel']; - -export function groupNameForAction(value: string): string { - const prefix = value.split(':')[0]; - return PREFIX_TO_GROUP_NAME[prefix] ?? capitalize(prefix); -} - -const VERB_TO_ICON: Record = { - read: 'visibility', - edit: 'edit', - add: 'add_circle', - create: 'add_circle', - delete: 'delete', - trigger: 'play_arrow', - diagram: 'schema', - 'ai-request': 'auto_awesome', -}; - -// Verb display overrides used inside labels (lowercase context). -// e.g. `actionLabel('table:ai-request')` → 'Table AI request'. -const VERB_DISPLAY_OVERRIDE: Record = { - 'ai-request': 'AI request', -}; - -const FALLBACK_ICON = 'help_outline'; -const WILDCARD_ICON = 'shield'; - -export function actionLabel(value: string): string { - if (value === '*') return 'Full access (all permissions)'; - const [prefix, verb = ''] = value.split(':'); - const prefixLabel = PREFIX_TO_LABEL[prefix] ?? capitalize(prefix); - if (verb === '*') return `Full ${prefixLabel.toLowerCase()} access`; - if (!verb) return prefixLabel; - return `${prefixLabel} ${verbInLabel(verb)}`; -} - -export function actionShortLabel(value: string): string { - if (value === '*') return 'Full access'; - const verb = value.split(':')[1] ?? ''; - if (verb === '*' || verb === '') return 'Full access'; - return capitalize(verbInLabel(verb)); -} - -export function actionIcon(value: string): string { - if (value === '*') return WILDCARD_ICON; - const verb = value.split(':')[1] ?? ''; - if (verb === '*') return WILDCARD_ICON; - return VERB_TO_ICON[verb] ?? FALLBACK_ICON; -} - -// Returns the verb as it should appear inside a sentence-case label -// (e.g. inside "Connection read"). For short labels, capitalize the first letter -// of the result. -function verbInLabel(verb: string): string { - const override = VERB_DISPLAY_OVERRIDE[verb]; - if (override) return override; - return verb.split('-').join(' '); -} - -function capitalize(text: string): string { - return text ? text.charAt(0).toUpperCase() + text.slice(1) : text; -} diff --git a/frontend/src/app/lib/setup-table-row-structure.spec.ts b/frontend/src/app/lib/setup-table-row-structure.spec.ts deleted file mode 100644 index bffeb7878..000000000 --- a/frontend/src/app/lib/setup-table-row-structure.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getTableTypes } from './setup-table-row-structure'; - -const fakeProduct_categories = { - column_name: 'product_categories', - column_default: null, - data_type: 'enum', - data_type_params: ['food', 'drinks', 'cleaning'], - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, -}; - -const fakeCustomer_categories = { - column_name: 'customer_categories', - column_default: null, - data_type: 'enum', - data_type_params: ['manager', 'seller'], - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, -}; - -const fakeCustomerId = { - column_name: 'CustomerId', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: false, - character_maximum_length: null, -}; - -const fakeProductId = { - column_name: 'ProductId', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: true, - auto_increment: false, - allow_null: false, - character_maximum_length: null, -}; - -const fakeBool = { - column_name: 'bool', - column_default: null, - data_type: 'tinyint', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 1, -}; - -const fakeFloat = { - column_name: 'float', - column_default: null, - data_type: 'float', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 102, -}; - -const fakeStructure = [ - fakeProduct_categories, - fakeCustomer_categories, - fakeCustomerId, - fakeProductId, - fakeBool, - fakeFloat, -]; - -const fakeForeignKeysList = ['CustomerId', 'ProductId']; - -describe('Normalize function', () => { - it('should return comparator of filter', () => { - const tableTypes = getTableTypes(fakeStructure, fakeForeignKeysList); - - expect(tableTypes).toEqual({ - product_categories: 'enum', - customer_categories: 'enum', - CustomerId: 'foreign key', - ProductId: 'foreign key', - bool: 'boolean', - float: 'float', - }); - }); -}); diff --git a/frontend/src/app/lib/setup-table-row-structure.ts b/frontend/src/app/lib/setup-table-row-structure.ts deleted file mode 100644 index 2ff53e113..000000000 --- a/frontend/src/app/lib/setup-table-row-structure.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TableField } from '../models/table'; - -export function getTableTypes(structure: TableField[], foreignKeysList: string[]) { - if (!structure || !Array.isArray(structure) || structure.length === 0) { - return {}; - } - - const tableTypes = Object.assign( - {}, - ...structure.map((field: TableField) => { - if (field.data_type === 'tinyint' && field.character_maximum_length === 1) - return { [field.column_name]: 'boolean' }; - if (foreignKeysList.includes(field.column_name)) return { [field.column_name]: 'foreign key' }; - return { [field.column_name]: field.data_type }; - }), - ); - return tableTypes; -} diff --git a/frontend/src/app/models/ai-suggestions.ts b/frontend/src/app/models/ai-suggestions.ts deleted file mode 100644 index 9e9f8bac5..000000000 --- a/frontend/src/app/models/ai-suggestions.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Input interfaces for AI Suggestions - -export interface AISuggestionColumn { - name: string; - type: string; - nullable: boolean; - unique?: boolean; - indexed?: boolean; -} - -export interface AISuggestionRelation { - type: 'fk'; - from_column: string; - to_table: string; - to_column: string; - label?: string; -} - -export interface AISuggestionLightStats { - top_values?: Record; - null_rate?: Record; - min_max?: Record; -} - -export interface AISuggestionTableContext { - name: string; - description?: string; - row_count?: number; - columns: AISuggestionColumn[]; - relations: AISuggestionRelation[]; - light_stats?: AISuggestionLightStats; -} - -export interface AISuggestionFilter { - column: string; - op: string; - value: any; -} - -export interface AISuggestionUIContext { - active_filters?: AISuggestionFilter[]; - selected_row?: Record; - selected_column?: string; -} - -export interface AISuggestionConversation { - last_user_message?: string; - last_2_messages?: { role: string; text: string }[]; -} - -export interface AISuggestionHistory { - recently_shown_ids?: string[]; - recently_clicked_ids?: string[]; -} - -export interface AISuggestionInput { - table: AISuggestionTableContext; - ui_context?: AISuggestionUIContext; - conversation?: AISuggestionConversation; - suggestion_history?: AISuggestionHistory; -} - -// Output interfaces for AI Suggestions - -export interface AITableSuggestion { - id: string; - title: string; - message: string; - why?: string; - confidence?: number; - risk?: 'low' | 'medium' | 'high'; -} - -export interface AINavigationSuggestion { - id: string; - title: string; - target_table: string; - prefilled_query: string; - why?: string; - confidence?: number; -} - -export interface AISuggestionOutput { - intent?: string[]; - table_suggestions: AITableSuggestion[]; - navigation_suggestions?: AINavigationSuggestion[]; -} diff --git a/frontend/src/app/models/alert.ts b/frontend/src/app/models/alert.ts deleted file mode 100644 index 58ec049cb..000000000 --- a/frontend/src/app/models/alert.ts +++ /dev/null @@ -1,31 +0,0 @@ -export enum AlertType { - Warning = 'warning', - Error = 'error', - Info = 'info', - Success = 'success', -} - -export enum AlertActionType { - Anchor = 'external link', - Link = 'internal link', - Button = 'button', -} - -export interface AlertAction { - type: AlertActionType; - caption: string; - to?: string; - action?: (id: number) => void; -} - -export interface Alert { - id: number; - type: AlertType; - message: ServerError | string; - actions?: AlertAction[]; -} - -export interface ServerError { - abstract: string; - details: string; -} diff --git a/frontend/src/app/models/company.ts b/frontend/src/app/models/company.ts deleted file mode 100644 index bc85566e0..000000000 --- a/frontend/src/app/models/company.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { User } from '@sentry/angular'; -import { SubscriptionPlans, UserGroup } from './user'; - -export interface Address { - street: string; - number: string; - complement: string; - neighborhood: string; - city: string; - state: string; - country: string; - zipCode: string; -} - -export interface CompanyConnection { - id: string; - createdAt: string; - updatedAt: string; - title: string; - author: User; - groups: UserGroup[]; -} - -export interface Company { - id: string; - additional_info?: string; - name: string; - address: Address | {}; - subscriptionLevel: SubscriptionPlans; - connections: CompanyConnection[]; - invitations: CompanyMemberInvitation[]; -} - -export interface CompanyMembershipGroup { - id: string; - title: string; -} - -export interface CompanyMembershipConnection { - id: string; - title: string; - database: string; - groups: CompanyMembershipGroup[]; -} - -export interface CompanyMember { - id: string; - isActive: boolean; - name: string; - email: string; - is_2fa_enabled: boolean; - role: CompanyMemberRole; - has_groups: boolean; - user_membership: CompanyMembershipConnection[]; -} - -export enum CompanyMemberRole { - CAO = 'ADMIN', - SystemAdmin = 'DB_ADMIN', - Member = 'USER', -} - -export interface CompanyMemberInvitation { - id: string; - verification_string: string; - groupId: string; - inviterId: string; - invitedUserEmail: string; - role: CompanyMemberRole; -} diff --git a/frontend/src/app/models/connection.ts b/frontend/src/app/models/connection.ts deleted file mode 100644 index a413ee3a3..000000000 --- a/frontend/src/app/models/connection.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { AccessLevel } from './user'; - -export enum DBtype { - Postgres = 'postgres', - MySQL = 'mysql', - Oracle = 'oracledb', - MSSQL = 'mssql', - Mongo = 'mongodb', - Dynamo = 'dynamodb', - Cassandra = 'cassandra', - Redis = 'redis', - Elasticsearch = 'elasticsearch', - ClickHouse = 'clickhouse', -} - -export enum ConnectionType { - Agent = 'agent', - Direct = 'direct', -} - -export interface Connection { - id: string | null; - database: string; - authSource?: string; - title: string; - host: string; - port: string; - sid: string | null; - type: DBtype; - createdAt?: Date; - updatedAt?: Date; - username: string; - password?: string; - ssh: boolean; - privateSSHKey?: string; - sshHost?: string; - sshPort?: string; - sshUsername?: string; - ssl: boolean; - cert: string; - masterEncryption: boolean; - azure_encryption: boolean; - connectionType: ConnectionType; - schema?: string; - signing_key?: string; - isTestConnection?: boolean; - dataCenter?: string; -} - -export interface ConnectionItem { - connection: Connection; - accessLevel: AccessLevel; -} - -export interface TestConnection { - result: boolean; - message: string; -} - -export interface TableCategory { - category_id: string; - category_name: string; - tables: string[]; - category_color?: string; -} - -export interface ConnectionSettings { - hidden_tables?: string[]; - default_showing_table?: string; - primary_color?: string; - secondary_color?: string; - logo_url?: string; - company_name?: string; - tables_audit?: boolean; - table_categories?: TableCategory[]; -} diff --git a/frontend/src/app/models/dashboard.ts b/frontend/src/app/models/dashboard.ts deleted file mode 100644 index 61a47242d..000000000 --- a/frontend/src/app/models/dashboard.ts +++ /dev/null @@ -1,45 +0,0 @@ -export interface Dashboard { - id: string; - name: string; - description: string | null; - connection_id: string; - created_at: string; - updated_at: string; - widgets?: DashboardWidget[]; -} - -export interface DashboardWidget { - id: string; - position_x: number; - position_y: number; - width: number; - height: number; - query_id: string | null; - dashboard_id: string; -} - -export interface CreateDashboardPayload { - name: string; - description?: string; -} - -export interface UpdateDashboardPayload { - name?: string; - description?: string; -} - -export interface CreateWidgetPayload { - position_x: number; - position_y: number; - width: number; - height: number; - query_id: string; -} - -export interface UpdateWidgetPayload { - position_x?: number; - position_y?: number; - width?: number; - height?: number; - query_id?: string; -} diff --git a/frontend/src/app/models/logs.ts b/frontend/src/app/models/logs.ts deleted file mode 100644 index d100ea727..000000000 --- a/frontend/src/app/models/logs.ts +++ /dev/null @@ -1,24 +0,0 @@ -export enum LogAction { - Add = 'addRow', - Delete = 'deleteRow', - Update = 'updateRow', - ReceiveRow = 'rowReceived', - ReceiveRows = 'rowsReceived', -} - -export enum LogStatus { - Successfully = 'successfully', - Unsuccessfully = 'unsuccessfully', -} - -export interface Log { - Table: string; - User: string; - Action: string; - Date: string; - Status: LogStatus; - operationType: LogAction; - createdAt: string; - prevValue: Object; - currentValue: Object; -} diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts deleted file mode 100644 index 998cf53de..000000000 --- a/frontend/src/app/models/saved-query.ts +++ /dev/null @@ -1,124 +0,0 @@ -export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; - -export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; - -export interface ChartUnitConfig { - text?: string; - position?: 'prefix' | 'suffix'; - convert_unit?: string; -} - -export interface ChartNumberFormatConfig { - decimal_places?: number; - thousands_separator?: boolean; - compact?: boolean; -} - -export interface ChartLegendConfig { - show?: boolean; - position?: 'top' | 'bottom' | 'left' | 'right'; -} - -export interface ChartAxisConfig { - title?: string; - min?: number; - max?: number; - begin_at_zero?: boolean; - scale_type?: 'linear' | 'logarithmic'; -} - -export interface ChartSeriesConfig { - value_column: string; - label?: string; - color?: string; - colors?: string[]; - units?: ChartUnitConfig; - number_format?: ChartNumberFormatConfig; - fill?: boolean; - tension?: number; - point_style?: 'circle' | 'rect' | 'triangle' | 'cross' | 'none'; - type?: 'bar' | 'line'; -} - -export interface ChartWidgetOptions { - label_column: string; - value_column?: string; - label_type?: 'values' | 'datetime'; - series?: ChartSeriesConfig[]; - series_column?: string; - units?: ChartUnitConfig; - number_format?: ChartNumberFormatConfig; - color_palette?: string[]; - legend?: ChartLegendConfig; - stacked?: boolean; - horizontal?: boolean; - show_data_labels?: boolean; - y_axis?: ChartAxisConfig; - x_axis?: ChartAxisConfig; - sort_by?: 'label_asc' | 'label_desc' | 'value_asc' | 'value_desc' | 'none'; - limit?: number; -} - -export interface SavedQuery { - id: string; - name: string; - description: string | null; - widget_type: DashboardWidgetType; - chart_type: ChartType | null; - widget_options: Record | null; - query_text: string; - connection_id: string; - created_at: string; - updated_at: string; -} - -export interface CreateSavedQueryPayload { - name: string; - description?: string; - widget_type?: DashboardWidgetType; - chart_type?: ChartType; - widget_options?: Record; - query_text: string; -} - -export interface UpdateSavedQueryPayload { - name?: string; - description?: string; - widget_type?: DashboardWidgetType; - chart_type?: ChartType; - widget_options?: Record; - query_text?: string; -} - -export interface QueryExecutionResult { - query_id: string; - query_name: string; - data: Record[]; - execution_time_ms: number; -} - -export interface TestQueryPayload { - query_text: string; - tableName?: string; -} - -export interface TestQueryResult { - data: Record[]; - execution_time_ms: number; -} - -export interface GeneratedPanelWithPosition { - name: string; - description: string | null; - widget_type: DashboardWidgetType; - chart_type: string | null; - widget_options: Record | null; - query_text: string; - connection_id: string; - panel_position: { - position_x: number; - position_y: number; - width: number; - height: number; - }; -} diff --git a/frontend/src/app/models/secret.ts b/frontend/src/app/models/secret.ts deleted file mode 100644 index 4fefb948a..000000000 --- a/frontend/src/app/models/secret.ts +++ /dev/null @@ -1,61 +0,0 @@ -export interface Secret { - id: string; - slug: string; - companyId: string; - createdAt: string; - updatedAt: string; - lastAccessedAt?: string; - expiresAt?: string; - masterEncryption: boolean; -} - -export interface SecretListResponse { - data: Secret[]; - pagination: SecretPagination; -} - -export interface SecretPagination { - total: number; - currentPage: number; - perPage: number; - lastPage: number; -} - -export interface AuditLogEntry { - id: string; - action: SecretAction; - user: { - id: string; - email: string; - }; - accessedAt: string; - ipAddress?: string; - userAgent?: string; - success: boolean; - errorMessage?: string; -} - -export interface AuditLogResponse { - data: AuditLogEntry[]; - pagination: SecretPagination; -} - -export type SecretAction = 'create' | 'view' | 'copy' | 'update' | 'delete'; - -export interface CreateSecretPayload { - slug: string; - value: string; - expiresAt?: string; - masterEncryption?: boolean; - masterPassword?: string; -} - -export interface UpdateSecretPayload { - value: string; - expiresAt?: string | null; -} - -export interface DeleteSecretResponse { - message: string; - deletedAt: string; -} diff --git a/frontend/src/app/models/table.ts b/frontend/src/app/models/table.ts deleted file mode 100644 index 3ba61dc9a..000000000 --- a/frontend/src/app/models/table.ts +++ /dev/null @@ -1,157 +0,0 @@ -export interface TablePermissions { - visibility: boolean; - readonly: boolean; - add: boolean; - delete: boolean; - edit: boolean; -} - -export interface TableProperties { - table: string; - display_name?: string; - normalizedTableName?: string; - initials?: string; - icon?: string; - permissions: TablePermissions; - configured?: boolean; - configuring?: boolean; -} - -export enum TableOrdering { - Ascending = 'ASC', - Descending = 'DESC', -} - -export interface TableSettings { - connection_id: string; - table_name: string; - - icon: string; - display_name: string; - autocomplete_columns: string[]; - identity_column: string; - ordering: TableOrdering; - ordering_field: string; - list_fields: string[]; - columns_view: string[]; - search_fields: string[]; - excluded_fields: string[]; - readonly_fields: string[]; - sortable_by: string[]; - sensitive_fields: string[]; - allow_csv_export: boolean; - allow_csv_import: boolean; - can_delete: boolean; -} - -export interface PersonalTableViewSettings { - ordering?: TableOrdering; - ordering_field?: string; - list_per_page?: number; - list_fields?: string[]; - columns_view?: string[]; - original_names?: boolean; - - // sortable_by: string[], -} - -export interface TableRow { - connectionID: string; - tableName: string; - record: object; - columnsOrder: string[]; - primaryKeys: object; - foreignKeys: TableForeignKey[]; - foreignKeysList: string[]; - widgets: Widget[]; - widgetsList: string[]; - fieldsTypes: { [key: string]: string }; - relatedRecords: { - referenced_on_column_name: string; - referenced_by: []; - }[]; - link?: string; -} - -export interface TableField { - column_name: string; - column_default: string; - data_type: string; - data_type_params?: string[]; - isExcluded: boolean; - isSearched: boolean; - allow_null: boolean; - auto_increment: boolean; - character_maximum_length: number; -} - -export interface TableForeignKey { - autocomplete_columns?: string[]; - column_name: string; - constraint_name: string; - referenced_column_name: string; - referenced_table_name: string; - column_default?: string; -} - -export interface WidgetStructure { - field_name: string; - widget_type: string; - widget_params: Record; - name: string; - description: string; -} - -export interface Widget { - field_name: string; - widget_type: string; - widget_params: string; - name: string; - description: string; -} - -export enum CustomActionType { - Single = 'single', - Multiple = 'multiple', -} - -export enum CustomActionMethod { - Slack = 'slack', - Email = 'email', - URL = 'URL', - Zapier = 'zapier', -} - -export interface CustomAction { - method: CustomActionMethod; - emails: string[]; - url: string; -} - -export enum EventType { - AddRow = 'ADD_ROW', - UpdateRow = 'UPDATE_ROW', - DeleteRow = 'DELETE_ROW', - Custom = 'CUSTOM', -} - -export interface Event { - event: EventType; -} - -export interface CustomEvent { - id?: string; - event: EventType; - title: string; - type: CustomActionType; - icon: string; - require_confirmation: boolean; -} - -export interface Rule { - id?: string; - title: string; - table_name: string; - events: (Event | CustomEvent)[]; - table_actions: CustomAction[]; -} diff --git a/frontend/src/app/models/ui-settings.ts b/frontend/src/app/models/ui-settings.ts deleted file mode 100644 index aec329e6e..000000000 --- a/frontend/src/app/models/ui-settings.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface GlobalSettingsUI { - connectionsListCollapsed: boolean; - lastFeatureNotificationId: string; - profileSidebarCollapsed?: boolean; -} - -export interface TableSettingsUI { - shownColumns?: string[]; - defaultSort?: { - column: string; - direction: 'asc' | 'desc'; - }; -} - -export interface ConnectionSettingsUI { - shownTableTitles: boolean; - tables: { [tableName: string]: TableSettingsUI }; - tableFoldersExpanded?: string[]; - dashboardsSidebarCollapsed?: boolean; -} - -export interface UiSettings { - globalSettings: GlobalSettingsUI; - connections: { [connectionId: string]: ConnectionSettingsUI }; -} diff --git a/frontend/src/app/models/user.ts b/frontend/src/app/models/user.ts deleted file mode 100644 index 0420670d4..000000000 --- a/frontend/src/app/models/user.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { CompanyMemberRole } from './company'; -import { TablePermissions } from './table'; - -export interface ExistingAuthUser { - email: string; - password: string; - companyId: string; -} - -export interface UserGroup { - id: string; - title: string; - isMain: boolean; - cedarPolicy?: string | null; - users?: { - id: string; - isActive: boolean; - email: string; - createdAt?: string; - name: string; - is_2fa_enabled: boolean; - role: CompanyMemberRole; - }[]; -} - -export interface UserGroupInfo { - group: UserGroup; - accessLevel: string; -} - -export interface GroupUser { - id: string; - createdAt: string; - gclid: string | null; - isActive: boolean; - email: string; - name?: string; -} - -export enum SubscriptionPlans { - free = 'FREE_PLAN', - team = 'TEAM_PLAN', - enterprise = 'ENTERPRISE_PLAN', - teamAnnual = 'ANNUAL_TEAM_PLAN', - enterpriseAnnual = 'ANNUAL_ENTERPRISE_PLAN', -} - -export enum RegistrationProvider { - Google = 'GOOGLE', - Github = 'GITHUB', -} - -export interface User { - id: string; - isActive: boolean; - email: string; - name?: string; - createdAt?: string; - subscriptionLevel: SubscriptionPlans; - is_2fa_enabled: boolean; - role: CompanyMemberRole; - externalRegistrationProvider: RegistrationProvider | null; - company: { - id: string; - }; -} - -export enum AccessLevel { - None = 'none', - Readonly = 'readonly', - Edit = 'edit', -} - -export interface TablePermission { - tableName: string; - display_name: string; - accessLevel: TablePermissions; -} - -export interface Permissions { - connection: { - connectionId: string; - accessLevel: AccessLevel; - }; - group: { - groupId: string; - accessLevel: AccessLevel; - }; - tables: TablePermission[]; -} - -// Public (unauthenticated) access to a connection. Mirrors the backend -// PublicTablePermissionDto: an omitted/empty readableColumns means "all columns". -export interface PublicTablePermission { - tableName: string; - readableColumns?: string[]; -} - -export interface PublicPermissions { - enabled: boolean; - tables: PublicTablePermission[]; -} - -export interface ApiKey { - title: string; - id: string; -} diff --git a/frontend/src/app/modules/config.module.ts b/frontend/src/app/modules/config.module.ts deleted file mode 100644 index 0d0dd555e..000000000 --- a/frontend/src/app/modules/config.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HttpClientModule } from '@angular/common/http'; -import { ModuleWithProviders, NgModule } from '@angular/core'; -import { ConfigurationService } from '../services/configuration.service'; - -@NgModule({ - providers: [ConfigurationService], - imports: [HttpClientModule], -}) -export class ConfigModule { - static buildForConfigUrl(configUrl: string): ModuleWithProviders { - return { - ngModule: ConfigModule, - providers: [ - { - provide: 'CONFIG_URL', - useValue: configUrl, - }, - ], - }; - } -} diff --git a/frontend/src/app/routes/company-invitation.routes.ts b/frontend/src/app/routes/company-invitation.routes.ts deleted file mode 100644 index c9210d966..000000000 --- a/frontend/src/app/routes/company-invitation.routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Routes } from '@angular/router'; -import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; - -export const COMPANY_INVITATION_ROUTES: Routes = [ - { - path: '', - loadComponent: () => - import('../components/company-member-invitation/company-member-invitation.component').then( - (m) => m.CompanyMemberInvitationComponent, - ), - title: 'Invitation | Rocketadmin', - providers: [provideZxvbnServiceForPSM()], - }, -]; diff --git a/frontend/src/app/routes/password-change.routes.ts b/frontend/src/app/routes/password-change.routes.ts deleted file mode 100644 index 3e5379209..000000000 --- a/frontend/src/app/routes/password-change.routes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Routes } from '@angular/router'; -import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; -import { AuthGuard } from '../guards/auth.guard'; - -export const PASSWORD_CHANGE_ROUTES: Routes = [ - { - path: '', - loadComponent: () => - import('../components/password-change/password-change.component').then((m) => m.PasswordChangeComponent), - canActivate: [AuthGuard], - providers: [provideZxvbnServiceForPSM()], - }, -]; diff --git a/frontend/src/app/routes/password-reset.routes.ts b/frontend/src/app/routes/password-reset.routes.ts deleted file mode 100644 index 2b1f66c58..000000000 --- a/frontend/src/app/routes/password-reset.routes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Routes } from '@angular/router'; -import { provideZxvbnServiceForPSM } from 'angular-password-strength-meter/zxcvbn'; - -export const PASSWORD_RESET_ROUTES: Routes = [ - { - path: '', - loadComponent: () => - import('../components/password-reset/password-reset.component').then((m) => m.PasswordResetComponent), - title: 'Reset password | Rocketadmin', - providers: [provideZxvbnServiceForPSM()], - }, -]; diff --git a/frontend/src/app/services/ai-suggestions.service.ts b/frontend/src/app/services/ai-suggestions.service.ts deleted file mode 100644 index 097e2b3a5..000000000 --- a/frontend/src/app/services/ai-suggestions.service.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { - AISuggestionInput, - AISuggestionOutput, - AISuggestionTableContext, - AISuggestionColumn, - AISuggestionRelation, - AISuggestionUIContext, - AISuggestionConversation, - AISuggestionHistory, - AITableSuggestion, -} from '../models/ai-suggestions'; -import { TableField, TableForeignKey } from '../models/table'; - -@Injectable({ - providedIn: 'root' -}) -export class AISuggestionsService { - - private suggestionsSubject = new BehaviorSubject(null); - public suggestions$ = this.suggestionsSubject.asObservable(); - - private historySubject = new BehaviorSubject({ - recently_shown_ids: [], - recently_clicked_ids: [] - }); - - constructor() {} - - /** - * Build table context from existing table data - */ - buildTableContext( - tableName: string, - displayName: string, - structure: TableField[], - foreignKeys: TableForeignKey[], - rowCount?: number - ): AISuggestionTableContext { - const columns: AISuggestionColumn[] = structure.map(field => ({ - name: field.column_name, - type: field.data_type, - nullable: field.allow_null, - unique: false, - indexed: false - })); - - const relations: AISuggestionRelation[] = foreignKeys.map(fk => ({ - type: 'fk' as const, - from_column: fk.column_name, - to_table: fk.referenced_table_name, - to_column: fk.referenced_column_name, - label: `${tableName}.${fk.column_name} -> ${fk.referenced_table_name}.${fk.referenced_column_name}` - })); - - return { - name: tableName, - description: displayName !== tableName ? displayName : undefined, - row_count: rowCount, - columns, - relations - }; - } - - /** - * Build UI context from current state - */ - buildUIContext( - activeFilters?: Record, - selectedRow?: Record, - selectedColumn?: string - ): AISuggestionUIContext { - const filters = activeFilters - ? Object.entries(activeFilters).map(([column, filterValue]) => { - const op = Object.keys(filterValue)[0] || 'eq'; - const value = Object.values(filterValue)[0]; - return { column, op, value }; - }) - : undefined; - - return { - active_filters: filters, - selected_row: selectedRow, - selected_column: selectedColumn - }; - } - - /** - * Build conversation context - */ - buildConversationContext( - messagesChain: { type: string; text: string }[] - ): AISuggestionConversation { - const lastUserMessage = [...messagesChain] - .reverse() - .find(m => m.type === 'user')?.text; - - const last2Messages = messagesChain.slice(-2).map(m => ({ - role: m.type === 'user' ? 'user' : 'assistant', - text: m.text - })); - - return { - last_user_message: lastUserMessage, - last_2_messages: last2Messages.length > 0 ? last2Messages : undefined - }; - } - - /** - * Build full suggestion input - */ - buildSuggestionInput( - tableContext: AISuggestionTableContext, - uiContext?: AISuggestionUIContext, - conversationContext?: AISuggestionConversation - ): AISuggestionInput { - return { - table: tableContext, - ui_context: uiContext, - conversation: conversationContext, - suggestion_history: this.historySubject.value - }; - } - - /** - * Generate local suggestions based on table context (fallback when no AI) - */ - generateLocalSuggestions(input: AISuggestionInput): AISuggestionOutput { - const suggestions: AITableSuggestion[] = []; - const { table, ui_context } = input; - - // Find datetime columns for trend analysis - const dateColumns = table.columns.filter(c => - c.type.toLowerCase().includes('date') || - c.type.toLowerCase().includes('time') || - c.name.toLowerCase().includes('created') || - c.name.toLowerCase().includes('updated') - ); - - // Find status/categorical columns - const statusColumns = table.columns.filter(c => - c.name.toLowerCase().includes('status') || - c.name.toLowerCase().includes('state') || - c.name.toLowerCase().includes('type') - ); - - // Find email/identifier columns - const identifierColumns = table.columns.filter(c => - c.name.toLowerCase().includes('email') || - c.name.toLowerCase().includes('name') || - c.name.toLowerCase() === 'id' - ); - - // Find numeric columns - const numericColumns = table.columns.filter(c => - c.type.toLowerCase().includes('int') || - c.type.toLowerCase().includes('decimal') || - c.type.toLowerCase().includes('numeric') || - c.type.toLowerCase().includes('float') - ); - - // Basic suggestions - suggestions.push({ - id: 'recent_rows', - title: 'Последние записи', - message: 'Покажи последние 10 записей в таблице', - confidence: 0.9, - risk: 'low' - }); - - suggestions.push({ - id: 'table_overview', - title: 'Обзор таблицы', - message: 'Дай краткий обзор структуры таблицы и основных данных', - confidence: 0.85, - risk: 'low' - }); - - // Status distribution if status column exists - if (statusColumns.length > 0) { - const col = statusColumns[0]; - suggestions.push({ - id: `group_by_${col.name}`, - title: `Распределение по ${col.name}`, - message: `Покажи распределение записей по полю "${col.name}" с количеством в каждой группе`, - confidence: 0.8, - risk: 'low' - }); - } - - // Trend analysis if date column exists - if (dateColumns.length > 0) { - const col = dateColumns[0]; - suggestions.push({ - id: `trend_by_${col.name}`, - title: `Тренд по ${col.name}`, - message: `Покажи тренд создания записей по полю "${col.name}" по месяцам`, - confidence: 0.75, - risk: 'low' - }); - } - - // Duplicates check if identifier column exists - if (identifierColumns.length > 0) { - const col = identifierColumns.find(c => c.name.toLowerCase().includes('email')) || identifierColumns[0]; - suggestions.push({ - id: `duplicates_by_${col.name}`, - title: `Дубликаты по ${col.name}`, - message: `Найди все дубликаты по полю "${col.name}" и покажи группы с количеством`, - confidence: 0.7, - risk: 'low' - }); - } - - // Null analysis - const nullableColumns = table.columns.filter(c => c.nullable); - if (nullableColumns.length > 0) { - suggestions.push({ - id: 'nulls_overview', - title: 'Анализ пустых значений', - message: 'Покажи статистику по NULL значениям во всех колонках', - confidence: 0.7, - risk: 'low' - }); - } - - // Statistics for numeric columns - if (numericColumns.length > 0) { - const col = numericColumns.find(c => - c.name.toLowerCase().includes('amount') || - c.name.toLowerCase().includes('price') || - c.name.toLowerCase().includes('total') - ) || numericColumns[0]; - suggestions.push({ - id: `stats_${col.name}`, - title: `Статистика по ${col.name}`, - message: `Рассчитай статистику (min, max, avg, sum) для поля "${col.name}"`, - confidence: 0.7, - risk: 'low' - }); - } - - // Context-aware suggestions based on active filters - if (ui_context?.active_filters && ui_context.active_filters.length > 0) { - suggestions.push({ - id: 'filtered_count', - title: 'Количество по фильтру', - message: 'Сколько записей соответствует текущему фильтру?', - confidence: 0.85, - risk: 'low' - }); - } - - // Take top 5-7 suggestions - const finalSuggestions = suggestions.slice(0, 7); - - return { - intent: this._detectIntent(input), - table_suggestions: finalSuggestions, - navigation_suggestions: this._generateNavigationSuggestions(input) - }; - } - - /** - * Record that a suggestion was clicked - */ - recordSuggestionClick(suggestionId: string): void { - const history = this.historySubject.value; - const clicked = [...(history.recently_clicked_ids || []), suggestionId].slice(-10); - this.historySubject.next({ - ...history, - recently_clicked_ids: clicked - }); - } - - /** - * Clear suggestion history - */ - clearHistory(): void { - this.historySubject.next({ - recently_shown_ids: [], - recently_clicked_ids: [] - }); - } - - /** - * Update shown history - */ - private updateShownHistory(shownIds: string[]): void { - const history = this.historySubject.value; - const shown = [...(history.recently_shown_ids || []), ...shownIds].slice(-20); - this.historySubject.next({ - ...history, - recently_shown_ids: shown - }); - } - - /** - * Detect user intent from context - */ - private _detectIntent(input: AISuggestionInput): string[] { - const intents: string[] = []; - const { ui_context, conversation } = input; - - const lastMessage = conversation?.last_user_message?.toLowerCase() || ''; - - if (lastMessage.includes('покажи') || lastMessage.includes('найди') || lastMessage.includes('где')) { - intents.push('SEARCH'); - } - if (lastMessage.includes('сколько') || lastMessage.includes('группир') || lastMessage.includes('распределен')) { - intents.push('SEGMENT'); - } - if (lastMessage.includes('дублик') || lastMessage.includes('null') || lastMessage.includes('пуст')) { - intents.push('QUALITY'); - } - if (lastMessage.includes('обнови') || lastMessage.includes('измени')) { - intents.push('UPDATE_HELP'); - } - - if (intents.length === 0) { - intents.push('EXPLORE'); - } - - return intents; - } - - /** - * Generate navigation suggestions for related tables - */ - private _generateNavigationSuggestions(input: AISuggestionInput) { - const { table, ui_context } = input; - const suggestions = []; - - if (table.relations.length > 0 && ui_context?.selected_row) { - for (const relation of table.relations.slice(0, 3)) { - const fkValue = ui_context.selected_row[relation.from_column]; - if (fkValue) { - suggestions.push({ - id: `nav_to_${relation.to_table}`, - title: `Открыть ${relation.to_table}`, - target_table: relation.to_table, - prefilled_query: `Покажи запись с ${relation.to_column} = ${fkValue}`, - why: `Связь ${relation.label}`, - confidence: 0.7 - }); - } - } - } - - return suggestions; - } -} diff --git a/frontend/src/app/services/ai.service.ts b/frontend/src/app/services/ai.service.ts deleted file mode 100644 index b1adc357a..000000000 --- a/frontend/src/app/services/ai.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { AiStreamChunk, ApiService } from './api.service'; - -export interface AiStreamResponse { - threadId: string; - stream: AsyncGenerator; -} - -@Injectable({ - providedIn: 'root', -}) -export class AiService { - private _api = inject(ApiService); - - async createThread( - connectionId: string, - tableName: string, - message: string, - signal?: AbortSignal, - ): Promise { - const result = await this._api.postStream( - `/ai/v4/request/${connectionId}`, - { user_message: message }, - { params: { tableName }, signal }, - ); - - if (!result) return null; - - return { - threadId: result.headers.get('X-OpenAI-Thread-ID'), - stream: result.stream, - }; - } - - async sendMessage( - connectionId: string, - tableName: string, - threadId: string, - message: string, - signal?: AbortSignal, - ): Promise | null> { - const result = await this._api.postStream( - `/ai/v4/request/${connectionId}`, - { user_message: message }, - { params: { tableName, threadId }, signal }, - ); - - return result?.stream ?? null; - } -} diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts deleted file mode 100644 index a36cf371c..000000000 --- a/frontend/src/app/services/api.service.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { HttpResourceOptions, HttpResourceRef, HttpResourceRequest, httpResource } from '@angular/common/http'; -import { effect, Injectable, inject } from '@angular/core'; -import { environment } from '../../environments/environment'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; - -export interface ApiRequestOptions { - params?: Record; - headers?: Record; - successMessage?: string; - errorMessage?: string; - responseType?: 'json' | 'text'; - signal?: AbortSignal; -} - -export interface ApiResponse { - data: T; - headers: Headers; - status: number; -} - -export type AiStreamChunk = - | { type: 'thinking'; content: string } - | { type: 'thinking_reset' } - | { type: 'thinking_commit' } - | { type: 'text'; content: string }; - -function tryParseChunk(line: string): AiStreamChunk | null { - try { - const parsed = JSON.parse(line); - if (parsed && typeof parsed.type === 'string') { - return parsed as AiStreamChunk; - } - } catch { - // ignore malformed line - } - return null; -} - -@Injectable({ - providedIn: 'root', -}) -export class ApiService { - private _notifications = inject(NotificationsService); - - resource( - request: () => string | HttpResourceRequest | undefined, - options?: HttpResourceOptions & { errorMessage?: string }, - ): HttpResourceRef { - const ref = httpResource(request as () => HttpResourceRequest | undefined, options); - - effect(() => { - const err = ref.error(); - if (err) { - this._handleError(err, { errorMessage: options?.errorMessage }); - } - }); - - return ref; - } - - async get(url: string, options?: ApiRequestOptions): Promise { - const result = await this._fetchResponse('GET', url, undefined, options); - return result?.data ?? null; - } - - async post(url: string, body?: unknown, options?: ApiRequestOptions): Promise { - const result = await this._fetchResponse('POST', url, body, options); - return result?.data ?? null; - } - - postResponse(url: string, body?: unknown, options?: ApiRequestOptions): Promise | null> { - return this._fetchResponse('POST', url, body, options); - } - - async put(url: string, body?: unknown, options?: ApiRequestOptions): Promise { - const result = await this._fetchResponse('PUT', url, body, options); - return result?.data ?? null; - } - - async delete(url: string, options?: ApiRequestOptions): Promise { - const result = await this._fetchResponse('DELETE', url, undefined, options); - return result?.data ?? null; - } - - async postStream( - url: string, - body?: unknown, - options?: ApiRequestOptions, - ): Promise<{ headers: Headers; stream: AsyncGenerator } | null> { - try { - const fullUrl = this._buildUrl(url, options?.params); - const headers = this._buildHeaders(options?.headers); - - const response = await fetch(fullUrl, { - method: 'POST', - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - credentials: 'include', - signal: options?.signal, - }); - - if (!response.ok) { - if (response.status === 401) { - localStorage.removeItem('token_expiration'); - location.href = '/login'; - return null; - } - - const errorBody = await response.json().catch(() => ({})); - throw { error: errorBody, status: response.status }; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - async function* chunkStream(): AsyncGenerator { - let buffer = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - const tail = buffer.trim(); - if (tail) { - const parsed = tryParseChunk(tail); - if (parsed) yield parsed; - } - break; - } - buffer += decoder.decode(value, { stream: true }); - let newlineIdx: number; - while ((newlineIdx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, newlineIdx).trim(); - buffer = buffer.slice(newlineIdx + 1); - if (!line) continue; - const parsed = tryParseChunk(line); - if (parsed) yield parsed; - } - } - } finally { - reader.releaseLock(); - } - } - - return { headers: response.headers, stream: chunkStream() }; - } catch (err: unknown) { - if ((err as Error).name === 'AbortError') return null; - this._handleError(err, options); - return null; - } - } - - private async _fetchResponse( - method: string, - url: string, - body?: unknown, - options?: ApiRequestOptions, - ): Promise | null> { - try { - const fullUrl = this._buildUrl(url, options?.params); - const headers = this._buildHeaders(options?.headers); - - const response = await fetch(fullUrl, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - credentials: 'include', - signal: options?.signal, - }); - - if (!response.ok) { - if (response.status === 401) { - localStorage.removeItem('token_expiration'); - location.href = '/login'; - return null; - } - - const errorBody = await response.json().catch(() => ({})); - throw { error: errorBody, status: response.status }; - } - - const data = - (options?.responseType ?? 'json') === 'text' ? ((await response.text()) as T) : ((await response.json()) as T); - - if (options?.successMessage) { - this._notifications.showSuccessSnackbar(options.successMessage); - } - - return { data, headers: response.headers, status: response.status }; - } catch (err: unknown) { - if ((err as Error).name === 'AbortError') return null; - this._handleError(err, options); - return null; - } - } - - private _buildUrl(url: string, params?: Record): string { - const normalized = this._normalizeUrl(url); - - if (!params || Object.keys(params).length === 0) return normalized; - - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - searchParams.set(key, String(value)); - } - } - - return `${normalized}?${searchParams.toString()}`; - } - - private _normalizeUrl(url: string): string { - if (url.startsWith('http://') || url.startsWith('https://')) return url; - if (url.startsWith('/saas')) return `${environment.saasURL || ''}${url}`; - return `${environment.apiRoot || '/api'}${url}`; - } - - private _buildHeaders(custom?: Record): Record { - const headers: Record = { - 'Content-Type': 'application/json', - }; - - const gclidMatch = document.cookie.match(/(?:^|;\s*)autoadmin_gclid=([^;]*)/); - if (gclidMatch?.[1]) { - headers.GCLID = gclidMatch[1]; - } - - const pathSegments = location.pathname.split('/'); - const connectionId = pathSegments.length >= 3 ? pathSegments[2] : null; - if (connectionId) { - const masterKey = localStorage.getItem(`${connectionId}__masterKey`); - if (masterKey) { - headers.masterpwd = masterKey; - } - } - - if (custom) { - Object.assign(headers, custom); - } - - return headers; - } - - private _handleError(err: unknown, options?: { errorMessage?: string }): void { - console.log(err); - const error = err as { error?: { message?: string; originalMessage?: string }; message?: string }; - const message = options?.errorMessage || error?.error?.message || error?.message || 'Unknown error'; - const details = error?.error?.originalMessage; - - if (details) { - this._notifications.showAlert(AlertType.Error, { abstract: message, details }, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ]); - } else { - this._notifications.showErrorSnackbar(message); - } - } -} diff --git a/frontend/src/app/services/auth.service.spec.ts b/frontend/src/app/services/auth.service.spec.ts deleted file mode 100644 index 748ce1d2c..000000000 --- a/frontend/src/app/services/auth.service.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { AlertActionType, AlertType } from '../models/alert'; -import { AuthService } from './auth.service'; -import { NotificationsService } from './notifications.service'; - -describe('AuthService', () => { - let service: AuthService; - let httpMock: HttpTestingController; - - let fakeNotifications; - - const fakeError = { - message: 'Auth error', - statusCode: 400, - originalMessage: 'Auth error details', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - AuthService, - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - ], - }); - - httpMock = TestBed.inject(HttpTestingController); - service = TestBed.inject(AuthService); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should call loginUser', () => { - let isSignUpUserCalled = false; - - const userData = { - email: 'john@smith.com', - password: 'mM87654321', - companyId: 'company_1', - }; - - const loginResponse = { - expires: '2022-04-11T15:56:51.599Z', - }; - - service.loginUser(userData).subscribe((res) => { - expect(res).toEqual(loginResponse); - isSignUpUserCalled = true; - }); - - const req = httpMock.expectOne(`/user/login`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(userData); - req.flush(loginResponse); - - expect(isSignUpUserCalled).toBe(true); - }); - - it('should fall for loginUser and show Error alert', async () => { - const userData = { - email: 'john@smith.com', - password: 'mM87654321', - companyId: 'company_1', - }; - - const tokenExpiration = service.loginUser(userData).toPromise(); - - const req = httpMock.expectOne(`/user/login`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await tokenExpiration; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call loginWith2FA', () => { - let isLoginWith2FACalled = false; - - const twofaResponse = {}; - - service.loginWith2FA('123456').subscribe((res) => { - expect(res).toEqual(twofaResponse); - isLoginWith2FACalled = true; - }); - - const req = httpMock.expectOne(`/user/otp/login`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(twofaResponse); - - expect(isLoginWith2FACalled).toBe(true); - }); - - it('should fall for loginWith2FA and show Error alert', async () => { - const twofaResponse = service.loginWith2FA('123456').toPromise(); - - const req = httpMock.expectOne(`/user/otp/login`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(fakeError, { status: 400, statusText: '' }); - await twofaResponse; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call loginWithGoogle', () => { - let isLoginWithGoogleCalled = false; - - const googleResponse = {}; - - service.loginWithGoogle('google-token-12345678').subscribe((res) => { - expect(res).toEqual(googleResponse); - isLoginWithGoogleCalled = true; - }); - - const req = httpMock.expectOne(`/saas/user/google/login`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ token: 'google-token-12345678' }); - req.flush(googleResponse); - - expect(isLoginWithGoogleCalled).toBe(true); - }); - - it('should fall for loginWithGoogle and show Error alert', async () => { - const googleResponse = service.loginWithGoogle('google-token-12345678').toPromise(); - - const req = httpMock.expectOne(`/saas/user/google/login`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ token: 'google-token-12345678' }); - req.flush(fakeError, { status: 400, statusText: '' }); - await googleResponse; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call requestEmailVerifications', () => { - let isRequestEmailVerificationsCalled = false; - - const googleResponse = {}; - - service.requestEmailVerifications().subscribe((res) => { - expect(res).toEqual(googleResponse); - isRequestEmailVerificationsCalled = true; - }); - - const req = httpMock.expectOne(`/user/email/verify/request`); - expect(req.request.method).toBe('GET'); - req.flush(googleResponse); - - expect(isRequestEmailVerificationsCalled).toBe(true); - }); - - it('should fall for requestEmailVerifications and show Error alert', async () => { - const googleResponse = service.requestEmailVerifications().toPromise(); - - const req = httpMock.expectOne(`/user/email/verify/request`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await googleResponse; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call verifyEmail', () => { - let isSignUpUserCalled = false; - - const verifyResponse = { - message: 'Email verified successfully', - }; - - service.verifyEmail('12345678').subscribe((res) => { - expect(res).toEqual(verifyResponse); - isSignUpUserCalled = true; - }); - - const req = httpMock.expectOne(`/user/email/verify/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(verifyResponse); - - expect(isSignUpUserCalled).toBe(true); - }); - - it('should fall for verifyEmail and show Error alert', async () => { - const verifyResponse = service.verifyEmail('12345678').toPromise(); - - const req = httpMock.expectOne(`/user/email/verify/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await verifyResponse; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call logOutUser', () => { - let isLogoutCalled = false; - - const logoutResponse = true; - - service.logOutUser().subscribe(() => { - isLogoutCalled = true; - }); - - const req = httpMock.expectOne(`/user/logout`); - expect(req.request.method).toBe('POST'); - req.flush(logoutResponse); - - expect(isLogoutCalled).toBe(true); - }); - - it('should fall for logOutUser and show Error snackbar', async () => { - const logoutResponse = service.logOutUser().toPromise(); - - const req = httpMock.expectOne(`/user/logout`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await logoutResponse; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); -}); diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts deleted file mode 100644 index f697b4f68..000000000 --- a/frontend/src/app/services/auth.service.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, signal } from '@angular/core'; -import * as Sentry from '@sentry/angular'; -import { BehaviorSubject, EMPTY } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { AlertActionType, AlertType } from '../models/alert'; -import { ExistingAuthUser } from '../models/user'; -import { ConfigurationService } from './configuration.service'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class AuthService { - private auth = new BehaviorSubject(''); - public cast = this.auth.asObservable(); - - private _isAuthenticated = signal(AuthService._hasValidSessionToken()); - public readonly isAuthenticated = this._isAuthenticated.asReadonly(); - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - private _configuration: ConfigurationService, - ) {} - - setAuthenticated(value: boolean): void { - this._isAuthenticated.set(value); - } - - loginUser(userData: ExistingAuthUser) { - if (userData.companyId === '') { - delete userData.companyId; - } - return this._http.post('/user/login', userData).pipe( - map((res) => { - this.auth.next(res); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - loginWith2FA(code: string) { - return this._http.post('/user/otp/login', { otpToken: code }).pipe( - map((res) => { - this.auth.next(res); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - loginWithGoogle(token: string) { - const config = this._configuration.getConfig(); - - return this._http.post(config.saasURL + '/saas/user/google/login', { token }).pipe( - map((res) => { - this.auth.next(res); - return res; - }), - catchError((err) => { - console.log(err); - Sentry.captureException(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - loginWithGithub() { - const config = this._configuration.getConfig(); - - location.assign(config.saasURL + '/saas/user/github/login/request'); - } - - requestEmailVerifications() { - return this._http.get('/user/email/verify/request').pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Confirmation email has been sent.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - verifyEmail(token: string) { - return this._http.get(`/user/email/verify/${token}`).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Your email is verified.'); - this.auth.next(res); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - fetchUserCompanies(email: string) { - return this._http.get(`/company/my/email/${email}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - checkInvitationAvailability(token: string) { - return this._http.get(`/company/invite/verify/${token}`).pipe( - map((res) => { - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - acceptCompanyInvitation(token: string, password: string, userName: string) { - return this._http - .post(`/company/invite/verify/${token}`, { - password, - userName, - }) - .pipe( - map((res) => { - this.auth.next(res); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - logOutUser() { - return this._http.post('/user/logout', undefined).pipe( - map(() => { - this._notifications.showSuccessSnackbar('User is logged out.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - private static _hasValidSessionToken(): boolean { - const exp = localStorage.getItem('token_expiration'); - if (!exp) return false; - const expiration = new Date(exp); - return !isNaN(expiration.getTime()) && expiration.getTime() > Date.now(); - } -} diff --git a/frontend/src/app/services/cedar-permission.service.spec.ts b/frontend/src/app/services/cedar-permission.service.spec.ts deleted file mode 100644 index 2ba7b44ff..000000000 --- a/frontend/src/app/services/cedar-permission.service.spec.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { signal, WritableSignal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { BehaviorSubject } from 'rxjs'; -import { User, UserGroupInfo } from '../models/user'; -import { CedarPermissionService } from './cedar-permission.service'; -import { CedarWasmService } from './cedar-wasm.service'; -import { UserService } from './user.service'; -import { UsersService } from './users.service'; -import { ViewAsService } from './view-as.service'; - -const CONN_ID = 'conn-abc'; -const USER_ID = 'user-1'; - -function makeGroup(id: string, cedarPolicy: string | null, userIds: string[]): UserGroupInfo { - return { - group: { - id, - title: `Group ${id}`, - isMain: false, - cedarPolicy, - users: userIds.map((uid) => ({ - id: uid, - isActive: true, - email: `${uid}@test.com`, - name: uid, - is_2fa_enabled: false, - role: 'MEMBER' as never, - })), - }, - accessLevel: 'edit', - }; -} - -function permitPolicy(action: string, resourceType: string, resourceId: string): string { - return [ - 'permit(', - ' principal,', - ` action == RocketAdmin::Action::"${action}",`, - ` resource == RocketAdmin::${resourceType}::"${resourceId}"`, - ');', - ].join('\n'); -} - -describe('CedarPermissionService', () => { - let service: CedarPermissionService; - let groupsSignal: WritableSignal; - let viewAsSignal: WritableSignal; - let userSubject: BehaviorSubject; - let mockIsAuthorized: ReturnType; - let mockIsAuthorizedPartial: ReturnType; - let mockCheckParsePolicySet: ReturnType; - let wasmLoaded: () => void; - - beforeEach(() => { - groupsSignal = signal([]); - viewAsSignal = signal(null); - userSubject = new BehaviorSubject(null); - - mockIsAuthorized = vi.fn().mockReturnValue({ - type: 'success', - response: { decision: 'deny', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - mockIsAuthorizedPartial = vi.fn().mockReturnValue({ - type: 'residuals', - response: { decision: 'deny', satisfied: [], errored: [], mayBeDetermining: [], mustBeDetermining: [] }, - warnings: [], - }); - - // Default: every policy parses successfully. Individual tests can override - // to simulate malformed policies that the merge step should drop. - mockCheckParsePolicySet = vi.fn().mockReturnValue({ type: 'success' }); - - let resolveWasm: (mod: unknown) => void; - const wasmPromise = new Promise((resolve) => { - resolveWasm = resolve; - }); - wasmLoaded = () => - resolveWasm!({ - isAuthorized: mockIsAuthorized, - isAuthorizedPartial: mockIsAuthorizedPartial, - checkParsePolicySet: mockCheckParsePolicySet, - }); - - TestBed.configureTestingModule({ - providers: [ - CedarPermissionService, - { - provide: CedarWasmService, - useValue: { load: () => wasmPromise }, - }, - { - provide: UserService, - useValue: { cast: userSubject.asObservable() }, - }, - { - provide: UsersService, - useValue: { groups: groupsSignal.asReadonly() }, - }, - { - provide: ViewAsService, - useValue: { viewAsGroupId: viewAsSignal.asReadonly() }, - }, - ], - }); - - service = TestBed.inject(CedarPermissionService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('canI signal returns null when WASM not loaded', () => { - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - }); - - it('canI signal returns null when no user', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - expect(mockIsAuthorized).not.toHaveBeenCalled(); - }); - - it('canI signal returns null when user has no groups', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - expect(mockIsAuthorized).not.toHaveBeenCalled(); - }); - - it('canI signal returns true when policy permits the action', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: ['policy0'], errors: [] } }, - warnings: [], - }); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(true); - expect(mockIsAuthorized).toHaveBeenCalledWith( - expect.objectContaining({ - principal: { type: 'RocketAdmin::User', id: USER_ID }, - action: { type: 'RocketAdmin::Action', id: 'connection:read' }, - resource: { type: 'RocketAdmin::Connection', id: CONN_ID }, - }), - ); - }); - - it('canI signal returns false when policy denies the action', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'deny', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - const sig = service.canI('connection:edit', 'Connection', CONN_ID); - expect(sig()).toBe(false); - }); - - it('merges policies from multiple groups', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const policy1 = permitPolicy('connection:read', 'Connection', CONN_ID); - const policy2 = permitPolicy('table:read', 'Table', `${CONN_ID}/users`); - groupsSignal.set([makeGroup('g1', policy1, [USER_ID]), makeGroup('g2', policy2, [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - service.canI('connection:read', 'Connection', CONN_ID)(); - - const call = mockIsAuthorized.mock.calls[0][0]; - expect(call.policies.staticPolicies).toContain('connection:read'); - expect(call.policies.staticPolicies).toContain('table:read'); - }); - - it('ignores groups the user does not belong to', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const userPolicy = permitPolicy('connection:read', 'Connection', CONN_ID); - const otherPolicy = permitPolicy('connection:edit', 'Connection', CONN_ID); - groupsSignal.set([makeGroup('g1', userPolicy, [USER_ID]), makeGroup('g2', otherPolicy, ['other-user'])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - service.canI('connection:read', 'Connection', CONN_ID)(); - - const call = mockIsAuthorized.mock.calls[0][0]; - expect(call.policies.staticPolicies).toContain('connection:read'); - expect(call.policies.staticPolicies).not.toContain('connection:edit'); - }); - - it('signal auto-refreshes when groups change', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - // Initially no groups → null (not yet determined) - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - - // Add a group with a permit policy - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - // Same signal should now return true - expect(sig()).toBe(true); - }); - - it('same args return same signal instance', () => { - const sig1 = service.canI('connection:read', 'Connection', CONN_ID); - const sig2 = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig1).toBe(sig2); - }); - - it('different args return different signal instances', () => { - const sig1 = service.canI('connection:read', 'Connection', CONN_ID); - const sig2 = service.canI('connection:edit', 'Connection', CONN_ID); - expect(sig1).not.toBe(sig2); - }); - - it('ready signal reflects state correctly', async () => { - expect(service.ready()).toBe(false); - - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - expect(service.ready()).toBe(false); // no user yet - - userSubject.next({ id: USER_ID } as User); - expect(service.ready()).toBe(false); // no groups yet - - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - expect(service.ready()).toBe(true); - }); - - it('returns false on isAuthorized failure', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'failure', - errors: [{ message: 'some error' }], - warnings: [], - }); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(false); - }); - - // canIAny tests (partial authorization) - - it('canIAny returns null when WASM not loaded', () => { - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('dashboard:read', 'Dashboard', `${CONN_ID}/dash-1`), [USER_ID])]); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(null); - }); - - it('canIAny returns null when no user', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - groupsSignal.set([makeGroup('g1', permitPolicy('dashboard:read', 'Dashboard', `${CONN_ID}/dash-1`), [USER_ID])]); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(null); - expect(mockIsAuthorizedPartial).not.toHaveBeenCalled(); - }); - - it('canIAny returns true when decision is allow (wildcard policy)', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', 'permit(principal, action, resource);', [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'residuals', - response: { decision: 'allow', satisfied: ['policy0'], errored: [], mayBeDetermining: [], mustBeDetermining: [] }, - warnings: [], - }); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(true); - }); - - it('canIAny returns true when decision is null (resource-dependent)', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('dashboard:read', 'Dashboard', `${CONN_ID}/dash-1`), [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'residuals', - response: { decision: null, satisfied: [], errored: [], mayBeDetermining: ['policy0'], mustBeDetermining: [] }, - warnings: [], - }); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(true); - }); - - it('canIAny returns false when decision is deny', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'residuals', - response: { decision: 'deny', satisfied: [], errored: [], mayBeDetermining: [], mustBeDetermining: [] }, - warnings: [], - }); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(false); - }); - - it('canIAny returns false on partial authorization failure', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('dashboard:read', 'Dashboard', `${CONN_ID}/dash-1`), [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'failure', - errors: [{ message: 'parse error' }], - warnings: [], - }); - - const sig = service.canIAny('dashboard:read', 'Dashboard'); - expect(sig()).toBe(false); - }); - - it('canIAny calls isAuthorizedPartial with resource null', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - groupsSignal.set([makeGroup('g1', permitPolicy('group:read', 'Group', 'g1'), [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'residuals', - response: { decision: null, satisfied: [], errored: [], mayBeDetermining: [], mustBeDetermining: [] }, - warnings: [], - }); - - service.canIAny('group:read', 'Group')(); - - expect(mockIsAuthorizedPartial).toHaveBeenCalledWith( - expect.objectContaining({ - principal: { type: 'RocketAdmin::User', id: USER_ID }, - action: { type: 'RocketAdmin::Action', id: 'group:read' }, - resource: null, - }), - ); - }); - - it('canIAny caches signals by action and resourceType', () => { - const sig1 = service.canIAny('dashboard:read', 'Dashboard'); - const sig2 = service.canIAny('dashboard:read', 'Dashboard'); - const sig3 = service.canIAny('group:read', 'Group'); - expect(sig1).toBe(sig2); - expect(sig1).not.toBe(sig3); - }); - - // View-as group override - - it('view-as: evaluates against the target group policy only, ignoring the user own groups', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const ownPolicy = permitPolicy('connection:edit', 'Connection', CONN_ID); - const targetPolicy = permitPolicy('connection:read', 'Connection', CONN_ID); - groupsSignal.set([makeGroup('own', ownPolicy, [USER_ID]), makeGroup('target', targetPolicy, ['other-user'])]); - - viewAsSignal.set('target'); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - service.canI('connection:read', 'Connection', CONN_ID)(); - - const call = mockIsAuthorized.mock.calls[0][0]; - expect(call.policies.staticPolicies).toContain('connection:read'); - expect(call.policies.staticPolicies).not.toContain('connection:edit'); - }); - - it('view-as: signal flips back to user own groups when cleared', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const ownPolicy = permitPolicy('connection:edit', 'Connection', CONN_ID); - const targetPolicy = permitPolicy('connection:read', 'Connection', CONN_ID); - groupsSignal.set([makeGroup('own', ownPolicy, [USER_ID]), makeGroup('target', targetPolicy, ['other-user'])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - - viewAsSignal.set('target'); - sig(); - expect(mockIsAuthorized.mock.calls.at(-1)![0].policies.staticPolicies).toContain('connection:read'); - expect(mockIsAuthorized.mock.calls.at(-1)![0].policies.staticPolicies).not.toContain('connection:edit'); - - viewAsSignal.set(null); - sig(); - expect(mockIsAuthorized.mock.calls.at(-1)![0].policies.staticPolicies).toContain('connection:edit'); - }); - - it('view-as: returns null when target group has no policy (engine not ready)', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - groupsSignal.set([makeGroup('target', null, ['other-user'])]); - viewAsSignal.set('target'); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - expect(mockIsAuthorized).not.toHaveBeenCalled(); - }); - - it('view-as: returns null when target group does not exist in groups list', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - groupsSignal.set([makeGroup('own', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); - viewAsSignal.set('does-not-exist'); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - expect(mockIsAuthorized).not.toHaveBeenCalled(); - }); - - // Resilient merge: drop individually unparseable policies - - it('resilient merge: drops a single unparseable policy and keeps the valid ones', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - const goodPolicy = permitPolicy('connection:read', 'Connection', CONN_ID); - const badPolicy = 'permit(principal, action like RocketAdmin::Action::"x:*", resource);'; - - // First policy parses, second does not - mockCheckParsePolicySet - .mockReturnValueOnce({ type: 'success' }) - .mockReturnValueOnce({ type: 'failure', errors: [{ message: 'unexpected token like' }] }); - - groupsSignal.set([makeGroup('g1', goodPolicy, [USER_ID]), makeGroup('g2', badPolicy, [USER_ID])]); - - mockIsAuthorized.mockReturnValue({ - type: 'success', - response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, - warnings: [], - }); - - service.canI('connection:read', 'Connection', CONN_ID)(); - - const call = mockIsAuthorized.mock.calls.at(-1)![0]; - expect(call.policies.staticPolicies).toContain('connection:read'); - expect(call.policies.staticPolicies).not.toContain('action like'); - }); - - it('resilient merge: a bad policy in one group does not block canIAny on others', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - // Wildcard policy from Admin group, plus an unparseable policy from another group - const adminPolicy = 'permit(principal, action, resource);'; - const badPolicy = 'permit(principal, action like RocketAdmin::Action::"dashboard:*", resource);'; - - mockCheckParsePolicySet.mockImplementation((arg: { staticPolicies: string }) => { - if (arg.staticPolicies.includes('action like')) { - return { type: 'failure', errors: [{ message: 'unexpected token like' }] }; - } - return { type: 'success' }; - }); - - groupsSignal.set([makeGroup('admin', adminPolicy, [USER_ID]), makeGroup('bad', badPolicy, [USER_ID])]); - - mockIsAuthorizedPartial.mockReturnValue({ - type: 'residuals', - response: { decision: 'allow', satisfied: ['policy0'], errored: [], mayBeDetermining: [], mustBeDetermining: [] }, - warnings: [], - }); - - const sig = service.canIAny('group:read', 'Group'); - expect(sig()).toBe(true); - - const call = mockIsAuthorizedPartial.mock.calls.at(-1)![0]; - expect(call.policies.staticPolicies).toContain('permit(principal, action, resource)'); - expect(call.policies.staticPolicies).not.toContain('action like'); - }); - - it('resilient merge: returns empty when all policies are unparseable', async () => { - wasmLoaded(); - await TestBed.inject(CedarWasmService).load(); - userSubject.next({ id: USER_ID } as User); - - mockCheckParsePolicySet.mockReturnValue({ type: 'failure', errors: [{ message: 'parse error' }] }); - - groupsSignal.set([ - makeGroup('g1', 'permit(principal, action like X, resource);', [USER_ID]), - makeGroup('g2', 'permit(principal, action like Y, resource);', [USER_ID]), - ]); - - const sig = service.canI('connection:read', 'Connection', CONN_ID); - expect(sig()).toBe(null); - expect(mockIsAuthorized).not.toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/app/services/cedar-permission.service.ts b/frontend/src/app/services/cedar-permission.service.ts deleted file mode 100644 index a60b09048..000000000 --- a/frontend/src/app/services/cedar-permission.service.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { computed, Injectable, inject, Signal, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { User } from '../models/user'; -import { CedarWasmService } from './cedar-wasm.service'; -import { UserService } from './user.service'; -import { UsersService } from './users.service'; -import { ViewAsService } from './view-as.service'; - -type CedarWasmModule = typeof import('@cedar-policy/cedar-wasm/web'); - -@Injectable({ providedIn: 'root' }) -export class CedarPermissionService { - private _cedarWasm = inject(CedarWasmService); - private _userService = inject(UserService); - private _usersService = inject(UsersService); - private _viewAs = inject(ViewAsService); - - private _currentUser = toSignal(this._userService.cast, { initialValue: null as User | null }); - private _userId = computed(() => this._currentUser()?.id || null); - private _wasmModule = signal(null); - - private _mergedPolicies = computed(() => { - const cedar = this._wasmModule(); - const groups = this._usersService.groups(); - const viewAsId = this._viewAs.viewAsGroupId(); - - const candidates: string[] = []; - - if (viewAsId) { - // View-as mode: evaluate against the target group's policy only, - // regardless of which groups the current user actually belongs to. - const target = groups.find((gi) => gi.group.id === viewAsId); - const policy = target?.group.cedarPolicy?.trim(); - if (policy) candidates.push(policy); - } else { - const userId = this._userId(); - if (!userId || !groups.length) return ''; - - for (const gi of groups) { - if (!gi.group.users?.some((u) => u.id === userId)) continue; - const policy = gi.group.cedarPolicy; - if (policy && policy.trim().length > 0) candidates.push(policy); - } - } - - if (candidates.length === 0) return ''; - - // Filter out individually-unparseable policies. Without this, a single - // malformed group policy would poison the merged set and Cedar would - // reject the whole authorization request, denying the user everything. - // We only filter when WASM is available; before then we return the raw - // merge — `ready` already gates evaluation on `_wasmModule`. - if (!cedar) return candidates.join('\n\n'); - - const valid: string[] = []; - for (const policy of candidates) { - const result = cedar.checkParsePolicySet({ staticPolicies: policy, templates: {} }); - if (result.type === 'success') { - valid.push(policy); - } else { - console.warn('Cedar: dropping unparseable group policy', result.errors); - } - } - return valid.join('\n\n'); - }); - - public readonly ready = computed(() => !!this._wasmModule() && !!this._mergedPolicies() && !!this._userId()); - - private _signals = new Map>(); - - constructor() { - this._cedarWasm.load().then((mod) => { - this._wasmModule.set(mod); - }); - } - - canI(action: string, resourceType: string, resourceId: string): Signal { - const key = `${action}|${resourceType}|${resourceId}`; - let sig = this._signals.get(key); - if (!sig) { - sig = computed(() => this._evaluate(action, resourceType, resourceId)); - this._signals.set(key, sig); - } - return sig; - } - - canIAny(action: string, resourceType: string): Signal { - const key = `any|${action}|${resourceType}`; - let sig = this._signals.get(key); - if (!sig) { - sig = computed(() => this._evaluatePartial(action, resourceType)); - this._signals.set(key, sig); - } - return sig; - } - - private _evaluatePartial(action: string, resourceType: string): boolean | null { - const cedar = this._wasmModule(); - const mergedPolicies = this._mergedPolicies(); - const userId = this._userId(); - - if (!cedar || !mergedPolicies || !userId) return null; - - const result = cedar.isAuthorizedPartial({ - principal: { type: 'RocketAdmin::User', id: userId }, - action: { type: 'RocketAdmin::Action', id: action }, - resource: null, - context: {}, - policies: { - staticPolicies: mergedPolicies, - templates: {}, - templateLinks: [], - }, - entities: [], - }); - - if (result.type === 'residuals') { - // decision is 'allow' (unconditionally allowed), 'deny' (unconditionally denied), - // or null (depends on resource — meaning at least one resource could be allowed) - return result.response.decision !== 'deny'; - } - - console.warn('Cedar partial authorization failed:', result.errors); - return false; - } - - private _evaluate(action: string, resourceType: string, resourceId: string): boolean | null { - const cedar = this._wasmModule(); - const mergedPolicies = this._mergedPolicies(); - const userId = this._userId(); - - if (!cedar || !mergedPolicies || !userId) return null; - - const result = cedar.isAuthorized({ - principal: { type: 'RocketAdmin::User', id: userId }, - action: { type: 'RocketAdmin::Action', id: action }, - resource: { type: `RocketAdmin::${resourceType}`, id: resourceId }, - context: {}, - policies: { - staticPolicies: mergedPolicies, - templates: {}, - templateLinks: [], - }, - entities: [], - }); - - if (result.type === 'success') { - return result.response.decision === 'allow'; - } - - console.warn('Cedar authorization failed:', result.errors); - return false; - } -} diff --git a/frontend/src/app/services/cedar-validator.service.ts b/frontend/src/app/services/cedar-validator.service.ts deleted file mode 100644 index e2ea0274b..000000000 --- a/frontend/src/app/services/cedar-validator.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { CedarWasmService } from './cedar-wasm.service'; - -export interface CedarValidationResult { - valid: boolean; - errors: string[]; -} - -@Injectable({ providedIn: 'root' }) -export class CedarValidatorService { - private _cedarWasm = inject(CedarWasmService); - - async validate(policyText: string): Promise { - if (!policyText.trim()) { - return { valid: true, errors: [] }; - } - - const cedar = await this._cedarWasm.load(); - const result = cedar.checkParsePolicySet({ - staticPolicies: policyText, - templates: {}, - templateLinks: [], - }); - - if (result.type === 'success') { - return { valid: true, errors: [] }; - } - - const errors = (result.errors ?? []).map((e: unknown) => { - if (typeof e === 'string') return e; - if (e && typeof e === 'object' && 'message' in e) return (e as { message: string }).message; - return String(e); - }); - return { valid: false, errors }; - } -} diff --git a/frontend/src/app/services/cedar-wasm.service.ts b/frontend/src/app/services/cedar-wasm.service.ts deleted file mode 100644 index fde14bbd7..000000000 --- a/frontend/src/app/services/cedar-wasm.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable } from '@angular/core'; - -type CedarWasmModule = typeof import('@cedar-policy/cedar-wasm/web'); - -@Injectable({ providedIn: 'root' }) -export class CedarWasmService { - private _module: CedarWasmModule | null = null; - private _loading: Promise | null = null; - - async load(): Promise { - if (this._module) return this._module; - if (this._loading) return this._loading; - - this._loading = (async () => { - const cedar = await import('@cedar-policy/cedar-wasm/web'); - const wasmBytes = await fetch('/assets/cedar-wasm/cedar_wasm_bg.wasm').then((r) => r.arrayBuffer()); - cedar.initSync({ module: new WebAssembly.Module(wasmBytes) }); - this._module = cedar; - return cedar; - })(); - - return this._loading; - } -} diff --git a/frontend/src/app/services/company.service.spec.ts b/frontend/src/app/services/company.service.spec.ts deleted file mode 100644 index 6e6f143c8..000000000 --- a/frontend/src/app/services/company.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { CompanyService } from './company.service'; - -describe('CompanyService', () => { - let service: CompanyService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [provideHttpClient()], - }); - service = TestBed.inject(CompanyService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/company.service.ts b/frontend/src/app/services/company.service.ts deleted file mode 100644 index 24725cbdf..000000000 --- a/frontend/src/app/services/company.service.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { BehaviorSubject, EMPTY } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { AlertActionType, AlertType } from '../models/alert'; -import { CompanyMemberRole } from '../models/company'; -import { ConfigurationService } from './configuration.service'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class CompanyService { - public saasHostnames = (environment as any).saasHostnames; - - private company = new BehaviorSubject(''); - public cast = this.company.asObservable(); - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - private _configuration: ConfigurationService, - ) {} - - isCustomDomain() { - const domain = window.location.hostname; - return !this.saasHostnames?.includes(domain); - } - - fetchCompany() { - return this._http.get(`/company/my/full`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - fetchCompanyMembers(companyId: string) { - return this._http.get(`/company/users/${companyId}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - fetchCompanyName(companyId: string) { - return this._http.get(`/company/name/${companyId}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - updateCompanyName(companyId: string, name: string) { - return this._http.put(`/company/name/${companyId}`, { name }).pipe( - map((_res) => this._notifications.showSuccessSnackbar('Company name has been updated.')), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - inviteCompanyMember( - companyId: string, - groupId: string, - email: string, - role: CompanyMemberRole, - turnstileToken?: string, - ) { - return this._http - .put(`/company/user/${companyId}`, { - groupId, - email, - role, - ...(turnstileToken ? { turnstileToken } : {}), - }) - .pipe( - map(() => { - this._notifications.showSuccessSnackbar(`Invitation link has been sent to ${email}.`); - this.company.next('invited'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - revokeInvitation(companyId: string, email: string) { - return this._http.put(`/company/invitation/revoke/${companyId}`, { email }).pipe( - map(() => { - this._notifications.showSuccessSnackbar(`Invitation has been revoked for ${email}.`); - this.company.next('revoked'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - removeCompanyMemder(companyId: string, userId: string, email: string, userName: string) { - return this._http.delete(`/company/${companyId}/user/${userId}`).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`${userName || email} has been removed from company.`); - this.company.next('deleted'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - updateCompanyMemberRole(companyId: string, userId: string, role: CompanyMemberRole) { - return this._http.put(`/company/users/roles/${companyId}`, { users: [{ userId, role }] }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`Company member role has been updated.`); - this.company.next('role'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - suspendCompanyMember(companyId: string, usersEmails: string[]) { - return this._http.put(`/company/users/suspend/${companyId}`, { usersEmails }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`Company member has been suspended.`); - this.company.next('suspended'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - restoreCompanyMember(companyId: string, usersEmails: string[]) { - return this._http.put(`/company/users/unsuspend/${companyId}`, { usersEmails }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`Company member has been restored.`); - this.company.next('unsuspended'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - getCustomDomain(companyId: string) { - const config = this._configuration.getConfig(); - - return this._http.get(config.saasURL + `/saas/custom-domain/${companyId}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - createCustomDomain(companyId: string, hostname: string) { - const config = this._configuration.getConfig(); - - return this._http.post(config.saasURL + `/saas/custom-domain/register/${companyId}`, { hostname }).pipe( - map((res) => { - // this._notifications.showAlert('Custom domain has been added.'); - this._notifications.showAlert( - AlertType.Success, - { - abstract: `Now your admin panel is live on your own domain: ${hostname}`, - details: - 'Check it out! If you have any issues or need help setting up your domain or CNAME record, please reach out to our support team.', - }, - [ - { - type: AlertActionType.Anchor, - caption: 'Open', - to: `https://${hostname}`, - }, - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - this.company.next('domain'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - updateCustomDomain(companyId: string, hostname: string) { - const config = this._configuration.getConfig(); - - return this._http.put(config.saasURL + `/saas/custom-domain/update/${companyId}`, { hostname }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Custom domain has been updated.'); - this.company.next('domain'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - deleteCustomDomain(companyId: string) { - const config = this._configuration.getConfig(); - - return this._http.delete(config.saasURL + `/saas/custom-domain/delete/${companyId}`).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Custom domain has been removed.'); - this.company.next('domain'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/configuration-state.service.ts b/frontend/src/app/services/configuration-state.service.ts deleted file mode 100644 index ac743f933..000000000 --- a/frontend/src/app/services/configuration-state.service.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; - -import { environment } from '../../environments/environment'; -import { NotificationsService } from './notifications.service'; - -export interface ConfigProgress { - type: 'message' | 'complete' | 'error'; - text?: string; - message?: string; -} - -@Injectable({ providedIn: 'root' }) -export class ConfigurationStateService { - private _notifications = inject(NotificationsService); - private _configuring = new BehaviorSubject>(new Set()); - - /** Latest progress event from the streaming setup endpoint (null when idle). */ - public progress$ = new BehaviorSubject(null); - - private _queue: ConfigProgress[] = []; - private _drainTimer: ReturnType | null = null; - private _currentDrainIntervalMs = 400; - private readonly _drainIntervalMsNormal = 400; - private readonly _drainIntervalMsFast = 80; - private readonly _drainCapAfterCompleteMs = 1500; - - /** Start configuring — opens a streaming connection, parses progress events, resolves when complete. */ - startConfiguring(connectionId: string): Promise { - if (this._configuring.value.has(connectionId)) return Promise.resolve(false); - - const current = this._configuring.value; - current.add(connectionId); - this._configuring.next(current); - - return this._runSetup(connectionId); - } - - /** Check if a connection is currently being configured */ - isConfiguring(connectionId: string): boolean { - return this._configuring.value.has(connectionId); - } - - private async _runSetup(connectionId: string): Promise { - this._queue = []; - this._stopDrain(); - this._currentDrainIntervalMs = this._drainIntervalMsNormal; - this._enqueue({ type: 'message', text: 'Starting configuration…' }); - try { - const url = `${environment.apiRoot || '/api'}/ai/v2/setup/${connectionId}`; - // Bypassing HttpClient (we need ReadableStream for SSE), so we replicate the masterpwd header that TokenInterceptor would normally inject. - const masterpwd = localStorage.getItem(`${connectionId}__masterKey`) || ''; - const response = await fetch(url, { - method: 'GET', - credentials: 'include', - headers: { masterpwd }, - }); - - if (!response.ok) { - throw new Error(`Setup failed with status ${response.status}`); - } - - if (response.body) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) { - const tail = buffer.trim(); - if (tail) this._parseAndEnqueue(tail); - break; - } - buffer += decoder.decode(value, { stream: true }); - let newlineIdx: number; - while ((newlineIdx = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, newlineIdx).trim(); - buffer = buffer.slice(newlineIdx + 1); - if (!line) continue; - this._parseAndEnqueue(line); - } - } - } - - await this._waitForDrain(); - this._notifications.showSuccessSnackbar('All tables have been configured.'); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : 'Configuration could not be completed.'; - this._queue = []; - this._stopDrain(); - this.progress$.next({ type: 'error', message }); - this._notifications.showErrorSnackbar(message); - return false; - } finally { - const current = this._configuring.value; - current.delete(connectionId); - this._configuring.next(current); - } - } - - private _parseAndEnqueue(line: string): void { - // Support SSE-style `data: {...}` lines as well as raw JSON lines. - const payload = line.startsWith('data:') ? line.slice(5).trim() : line; - if (!payload || payload === '[DONE]') return; - try { - const parsed = JSON.parse(payload) as ConfigProgress; - // `complete` keeps order with the trailing messages but speeds up drain - // so the remaining log flushes quickly instead of pacing at the normal rate. - if (parsed.type === 'complete') { - this._enqueue({ type: 'message', text: '✓ Setup complete' }); - this._enqueue(parsed); - this._switchDrainSpeed(this._drainIntervalMsFast); - return; - } - this._enqueue(parsed); - } catch { - // Ignore non-JSON keepalive/comment frames. - } - } - - private _enqueue(p: ConfigProgress): void { - this._queue.push(p); - this._startDrain(); - } - - private _startDrain(): void { - if (this._drainTimer) return; - // Emit first item immediately, then pace the rest. - this._drainOne(); - this._drainTimer = setInterval(() => this._drainOne(), this._currentDrainIntervalMs); - } - - private _switchDrainSpeed(intervalMs: number): void { - this._currentDrainIntervalMs = intervalMs; - if (this._drainTimer) { - clearInterval(this._drainTimer); - this._drainTimer = setInterval(() => this._drainOne(), intervalMs); - } - } - - private _drainOne(): void { - const next = this._queue.shift(); - if (!next) { - this._stopDrain(); - return; - } - this.progress$.next(next); - } - - private _stopDrain(): void { - if (this._drainTimer) { - clearInterval(this._drainTimer); - this._drainTimer = null; - } - } - - private _waitForDrain(): Promise { - // Switch to fast drain on stream end so we don't add long display tail. - this._switchDrainSpeed(this._drainIntervalMsFast); - const start = Date.now(); - return new Promise((resolve) => { - const check = (): void => { - if (this._queue.length === 0 && !this._drainTimer) { - resolve(); - return; - } - if (Date.now() - start >= this._drainCapAfterCompleteMs) { - // Cap reached — flush whatever is left so we don't delay navigation further. - while (this._queue.length > 0) this._drainOne(); - this._stopDrain(); - resolve(); - return; - } - setTimeout(check, this._drainIntervalMsFast); - }; - check(); - }); - } -} diff --git a/frontend/src/app/services/configuration.service.spec.ts b/frontend/src/app/services/configuration.service.spec.ts deleted file mode 100644 index 5e5b1c928..000000000 --- a/frontend/src/app/services/configuration.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ConfigurationService } from './configuration.service'; - -describe('ConfigurationService', () => { - let service: ConfigurationService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - { - provide: ConfigurationService, - useValue: { CONFIG_URL: 'https://app.rocketadmin.com/api' }, - }, - ], - }); - service = TestBed.inject(ConfigurationService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/configuration.service.ts b/frontend/src/app/services/configuration.service.ts deleted file mode 100644 index 5945d0f90..000000000 --- a/frontend/src/app/services/configuration.service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@angular/core'; -import config from '../../config.json'; - -@Injectable({ - providedIn: 'root', -}) -export class ConfigurationService { - getConfig() { - return config; - } -} diff --git a/frontend/src/app/services/connections.service.spec.ts b/frontend/src/app/services/connections.service.spec.ts deleted file mode 100644 index 8ba7be0f0..000000000 --- a/frontend/src/app/services/connections.service.spec.ts +++ /dev/null @@ -1,829 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { of } from 'rxjs'; -import { AlertActionType, AlertType } from '../models/alert'; -import { ConnectionType, DBtype } from '../models/connection'; -import { AccessLevel } from '../models/user'; -import { CedarPermissionService } from './cedar-permission.service'; -import { ConnectionsService } from './connections.service'; -import { MasterPasswordService } from './master-password.service'; -import { NotificationsService } from './notifications.service'; -import { UsersService } from './users.service'; - -describe('ConnectionsService', () => { - let httpMock: HttpTestingController; - let service: ConnectionsService; - - let fakeNotifications; - let fakeMasterPassword; - let mockCanI: ReturnType; - let mockCanIAny: ReturnType; - let mockPermissions: Partial; - - const connectionCredsApp = { - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: '3306', - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: '22', - sshUsername: 'ubuntu', - ssl: false, - cert: null, - connectionType: ConnectionType.Direct, - azure_encryption: false, - signing_key: '', - }; - - const connectionCredsRequested = { - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - connectionType: ConnectionType.Direct, - azure_encryption: false, - signing_key: '', - }; - - const connectionCredsNetwork = { - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - azure_encryption: false, - signing_key: '', - }; - - const fakeError = { - message: 'Connection error', - statusCode: 400, - type: 'no_master_key', - originalMessage: 'Connection error details', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - dismissAlert: vi.fn(), - }; - fakeMasterPassword = { - showMasterPasswordDialog: vi.fn(), - checkMasterPassword: vi.fn(), - }; - mockCanI = vi.fn().mockReturnValue(signal(false).asReadonly()); - mockCanIAny = vi.fn().mockReturnValue(signal(false).asReadonly()); - mockPermissions = { - canI: mockCanI, - canIAny: mockCanIAny, - ready: signal(false).asReadonly(), - }; - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - provideRouter([]), - ConnectionsService, - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - { - provide: MasterPasswordService, - useValue: fakeMasterPassword, - }, - { - provide: UsersService, - useValue: { setActiveConnection: vi.fn() }, - }, - { - provide: CedarPermissionService, - useValue: mockPermissions, - }, - ], - }); - - httpMock = TestBed.inject(HttpTestingController); - service = TestBed.inject(ConnectionsService); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should set connectionID', () => { - service.setConnectionID('12345678'); - expect(service.connectionID).toEqual('12345678'); - }); - - it('should get currentConnectionID', () => { - service.connectionID = '12345678'; - expect(service.currentConnectionID).toEqual('12345678'); - }); - - it('should set connectionInfo if connection exists', () => { - const fakeFetchConnection = vi.spyOn(service, 'fetchConnection').mockReturnValue( - of({ - connection: connectionCredsApp, - accessLevel: 'edit', - groupManagement: true, - }), - ); - service.setConnectionInfo('12345678'); - expect(service.connection).toEqual(connectionCredsApp); - expect(service.connectionAccessLevel).toEqual('edit'); - expect(service.groupsAccessLevel).toEqual(true); - - fakeFetchConnection.mockClear(); - }); - - it('should set connectionInfo in initial state if connection does not exist', () => { - service.setConnectionInfo(null); - expect(service.connection).toEqual(service.connectionInitialState); - }); - - it('should get currentConnection', () => { - service.connection = connectionCredsApp; - expect(service.currentConnection).toEqual(connectionCredsApp); - }); - - it('should get currentConnectionAccessLevel', () => { - service.connectionAccessLevel = AccessLevel.Edit; - expect(service.currentConnectionAccessLevel).toEqual('edit'); - }); - - it('should get currentConnectionGroupAccessLevel', () => { - service.groupsAccessLevel = false; - expect(service.currentConnectionGroupAccessLevel).toEqual(false); - }); - - it('should define a type of connections without agent_ prefix', () => { - const connection = service.defineConnectionType(connectionCredsNetwork); - - expect(connection).toEqual({ - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - azure_encryption: false, - connectionType: ConnectionType.Direct, - signing_key: '', - }); - }); - - it('should define a type of connections with agent_ prefix', () => { - const connection = service.defineConnectionType({ - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: 'agent_mysql', - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - azure_encryption: false, - }); - - expect(connection).toEqual({ - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: DBtype.MySQL, - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - id: '9d5f6d0f-9516-4598-91c4-e4fe6330b4d4', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - azure_encryption: false, - connectionType: ConnectionType.Agent, - }); - }); - - it('should get current page slug', () => { - service.currentPage = 'dashboard'; - expect(service.currentTab).toEqual('dashboard'); - }); - - it('should get visible tabs dashboard and audit when all permissions denied', () => { - expect(service.visibleTabs).toEqual(['dashboard', 'audit']); - }); - - it('should show dashboards tab when canIAny dashboard:read returns true', () => { - mockCanIAny.mockImplementation((action: string) => signal(action === 'dashboard:read').asReadonly()); - expect(service.visibleTabs).toContain('dashboards'); - expect(service.visibleTabs).not.toContain('permissions'); - }); - - it('should show permissions tab when canIAny group:read returns true', () => { - mockCanIAny.mockImplementation((action: string) => signal(action === 'group:read').asReadonly()); - expect(service.visibleTabs).toContain('permissions'); - expect(service.visibleTabs).not.toContain('dashboards'); - }); - - it('should show all tabs when all permissions granted', () => { - mockCanI.mockReturnValue(signal(true).asReadonly()); - mockCanIAny.mockReturnValue(signal(true).asReadonly()); - expect(service.visibleTabs).toContain('dashboards'); - expect(service.visibleTabs).toContain('permissions'); - expect(service.visibleTabs).toContain('connection-settings'); - expect(service.visibleTabs).toContain('edit-db'); - }); - - it('should call fetchConnections', () => { - let isSubscribeCalled = false; - const connectionsList = { - connections: [ - { - connection: { - id: '3d5ecd09-b3a8-486a-a0ec-ff36c8d69a17', - title: 'Test connection to OracleDB', - masterEncryption: true, - type: 'oracledb', - host: 'database-1.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 1521, - username: 'U2FsdGVkX19+rqtUQ3uLCM9fdaxIpXvfW6VzUhB8Geg=', - database: 'U2FsdGVkX1/AlO3GRqUxPnTaJDtYB+HkGQ4mUOdPKlY=', - schema: null, - sid: 'ORCL', - createdAt: '2021-01-04T11:59:37.641Z', - updatedAt: '2021-06-07T15:05:39.829Z', - ssh: false, - sshHost: null, - sshPort: null, - sshUsername: null, - ssl: false, - cert: null, - isTestConnection: true, - }, - accessLevel: 'edit', - displayTitle: 'Test connection to OracleDB', - }, - { - connection: { - id: 'bb66a2fc-a52e-4809-8542-7fa78c466e03', - title: 'Test connection via SSH tunnel to mySQL', - masterEncryption: false, - type: 'mysql', - host: 'database-2.cvfuxe8nltiq.us-east-2.rds.amazonaws.com', - port: 3306, - username: 'admin', - database: 'testDB', - schema: null, - sid: null, - createdAt: '2020-12-24T20:13:30.327Z', - updatedAt: '2020-12-24T20:13:30.327Z', - ssh: true, - sshHost: '3.134.99.192', - sshPort: 22, - sshUsername: 'ubuntu', - ssl: false, - cert: null, - isTestConnection: true, - }, - accessLevel: 'readonly', - displayTitle: 'Test connection via SSH tunnel to mySQL', - }, - ], - connectionsCount: 2, - }; - - service.fetchConnections().subscribe((connectionData) => { - expect(connectionData).toEqual(connectionsList.connections); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connections`); - expect(req.request.method).toBe('GET'); - req.flush(connectionsList); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall with error for fetchConnections and show Error alert', async () => { - const connections = service.fetchConnections().toPromise(); - const req = httpMock.expectOne(`/connections`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await connections; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call fetchConnection', () => { - let isSubscribeCalled = false; - const connectionItem = { - connection: connectionCredsNetwork, - accessLevel: 'edit', - groupManagement: true, - }; - - service.fetchConnection('12345678').subscribe((connectionData) => { - expect(connectionData).toEqual(connectionItem); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/one/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(connectionItem); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall for fetchConnection and show Error snackbar', async () => { - const connection = service.fetchConnection('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/one/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await connection; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should fall for fetchConnection and show Master password request', async () => { - // router should to be mocked - - const connection = service.fetchConnection('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/one/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await connection; - - expect(fakeMasterPassword.showMasterPasswordDialog).toHaveBeenCalled(); - }); - - it('should call testConnection', () => { - let isSubscribeCalled = false; - - service.testConnection('12345678', connectionCredsApp).subscribe((res) => { - expect(res).toEqual(true); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/test?connectionId=12345678`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(true); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall testConnection and show Error alert', async () => { - const isConnectionWorks = service.testConnection('12345678', connectionCredsApp).toPromise(); - - const req = httpMock.expectOne(`/connection/test?connectionId=12345678`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(fakeError, { status: 400, statusText: '' }); - await isConnectionWorks; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [], - ); - }); - - it('should call createConnection and show Success Snackbar', async () => { - service.createConnection(connectionCredsApp, 'master_key_12345678').subscribe((res) => { - expect(res).toEqual(connectionCredsNetwork); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Connection was added successfully.'); - }); - - const req = httpMock.expectOne(`/connection`); - expect(req.request.method).toBe('POST'); - expect(req.request.headers.get('masterpwd')).toBe('master_key_12345678'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(connectionCredsNetwork); - }); - - it('should fall for createConnection and show Error alert', async () => { - const createdConnection = service.createConnection(connectionCredsApp, 'master_key_12345678').toPromise(); - - const req = httpMock.expectOne(`/connection`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(fakeError, { status: 400, statusText: '' }); - - await expect(createdConnection).rejects.toEqual(fakeError.message); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [], - ); - }); - - it('should call updateConnection and show Success Snackbar', async () => { - service.updateConnection(connectionCredsApp, 'master_key_12345678').subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Connection has been updated successfully.'); - }); - - const req = httpMock.expectOne(`/connection/9d5f6d0f-9516-4598-91c4-e4fe6330b4d4`); - expect(req.request.method).toBe('PUT'); - expect(req.request.headers.get('masterpwd')).toBe('master_key_12345678'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(connectionCredsNetwork); - }); - - it('should fall for updateConnection and show Error alert', () => { - let errorCaught = false; - - service.updateConnection(connectionCredsApp, 'master_key_12345678').subscribe({ - next: () => { - // Should not be called - }, - error: (error) => { - errorCaught = true; - expect(error.message).toEqual(fakeError.message); - }, - }); - - const req = httpMock.expectOne(`/connection/9d5f6d0f-9516-4598-91c4-e4fe6330b4d4`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual(connectionCredsRequested); - req.flush(fakeError, { status: 400, statusText: '' }); - - expect(errorCaught).toBe(true); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call deleteConnection and show Success Snackbar', () => { - let isSubscribeCalled = false; - const metadata = { - reason: 'missing-features', - message: 'i want to add tables', - }; - - service.deleteConnection('12345678', metadata).subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Connection has been deleted successfully.'); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/delete/12345678`); - expect(req.request.method).toBe('PUT'); - req.flush(connectionCredsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall for deleteConnection and show Error snackbar', async () => { - const metadata = { - reason: 'missing-features', - message: 'i want to add tables', - }; - - const deletedConnection = service.deleteConnection('12345678', metadata).toPromise(); - - const req = httpMock.expectOne(`/connection/delete/12345678`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await deletedConnection; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call fetchAuditLog', () => { - let isSubscribeCalled = false; - - service - .fetchAuditLog({ - connectionID: '12345678', - tableName: 'users_table', - userEmail: 'eric.cartman@south.park', - requstedPage: 2, - chunkSize: 10, - }) - .subscribe((_res) => { - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne( - `/logs/12345678?page=2&perPage=10&tableName=users_table&email=eric.cartman@south.park`, - ); - expect(req.request.method).toBe('GET'); - req.flush({}); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchAuditLog and exclude tableName param if all tables are requested', () => { - let isSubscribeCalled = false; - - service - .fetchAuditLog({ - connectionID: '12345678', - tableName: 'showAll', - userEmail: 'eric.cartman@south.park', - requstedPage: 2, - chunkSize: 10, - }) - .subscribe((_res) => { - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/logs/12345678?page=2&perPage=10&email=eric.cartman@south.park`); - req.flush({}); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchAuditLog and exclude email param if all users are requested', () => { - let isSubscribeCalled = false; - - service - .fetchAuditLog({ - connectionID: '12345678', - tableName: 'users_table', - userEmail: 'showAll', - requstedPage: 2, - chunkSize: 10, - }) - .subscribe((_res) => { - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/logs/12345678?page=2&perPage=10&tableName=users_table`); - req.flush({}); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall for fetchAuditLog and show Error snackbar', async () => { - const logs = service - .fetchAuditLog({ - connectionID: '12345678', - tableName: 'users_table', - userEmail: 'eric.cartman@south.park', - requstedPage: 2, - chunkSize: 10, - }) - .toPromise(); - - const req = httpMock.expectOne( - `/logs/12345678?page=2&perPage=10&tableName=users_table&email=eric.cartman@south.park`, - ); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await logs; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call getConnectionSettings', async () => { - const connectionSettingsNetwork = { - id: 'dd58c614-866d-4293-8c65-351c667d29ca', - connectionId: '12345678', - logo_url: 'https://example.com/logo.png', - company_name: 'Example Company', - primary_color: '#123456', - secondary_color: '#654321', - hidden_tables: ['users', 'orders'], - tables_audit: true, - }; - - const mockThemeService = { updateColors: vi.fn() }; - service._themeService = mockThemeService as any; - - const promise = service.getConnectionSettings('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(connectionSettingsNetwork); - - const res = await promise; - expect(res).toEqual(connectionSettingsNetwork); - expect(mockThemeService.updateColors).toHaveBeenCalledWith({ - palettes: { - primaryPalette: '#123456', - accentedPalette: '#654321', - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - }); - - it('should fall for getConnectionSettings and show Error snackbar', async () => { - const settings = service.getConnectionSettings('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await settings; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(`${fakeError.message}.`); - }); - - it('should call createConnectionSettings and show success snackbar', () => { - let isSubscribeCalled = false; - - service - .createConnectionSettings('12345678', { hidden_tables: ['users', 'orders'], default_showing_table: '' }) - .subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'Connection settings has been created successfully.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - hidden_tables: ['users', 'orders'], - default_showing_table: '', - }); - req.flush({ - id: 'dd58c614-866d-4293-8c65-351c667d29ca', - hidden_tables: ['users', 'orders'], - connectionId: '12345678', - }); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall createConnectionSettings and show Error alert', async () => { - const createSettings = service - .createConnectionSettings('12345678', { hidden_tables: ['users', 'orders'], default_showing_table: '' }) - .toPromise(); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - hidden_tables: ['users', 'orders'], - default_showing_table: '', - }); - req.flush(fakeError, { status: 400, statusText: '' }); - await createSettings; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(`${fakeError.message}.`); - }); - - it('should call updateConnectionSettings and show success snackbar', () => { - let isSubscribeCalled = false; - - service - .updateConnectionSettings('12345678', { - hidden_tables: ['users', 'orders', 'products'], - default_showing_table: 'users', - }) - .subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'Connection settings has been updated successfully.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ - hidden_tables: ['users', 'orders', 'products'], - default_showing_table: 'users', - }); - req.flush({ - id: 'dd58c614-866d-4293-8c65-351c667d29ca', - hidden_tables: ['users', 'orders', 'products'], - connectionId: '12345678', - }); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall createConnectionSettings and show Error alert', async () => { - const updateSettings = service - .updateConnectionSettings('12345678', { - hidden_tables: ['users', 'orders', 'products'], - default_showing_table: 'users', - }) - .toPromise(); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ - hidden_tables: ['users', 'orders', 'products'], - default_showing_table: 'users', - }); - req.flush(fakeError, { status: 400, statusText: '' }); - await updateSettings; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(`${fakeError.message}.`); - }); - - it('should call deleteConnectionSettings and show success snackbar', () => { - let isSubscribeCalled = false; - - service.deleteConnectionSettings('12345678').subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'Connection settings has been removed successfully.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('DELETE'); - req.flush({}); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall deleteConnectionSettings and show Error alert', async () => { - const deleteSettings = service.deleteConnectionSettings('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/properties/12345678`); - expect(req.request.method).toBe('DELETE'); - req.flush(fakeError, { status: 400, statusText: '' }); - await deleteSettings; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(`${fakeError.message}.`); - }); -}); diff --git a/frontend/src/app/services/connections.service.ts b/frontend/src/app/services/connections.service.ts deleted file mode 100644 index 3b57d9ce6..000000000 --- a/frontend/src/app/services/connections.service.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { IColorConfig, NgxThemeService } from '@brumeilde/ngx-theme'; -import { BehaviorSubject, EMPTY, throwError } from 'rxjs'; -import { catchError, filter, map } from 'rxjs/operators'; -import { AlertActionType, AlertType } from '../models/alert'; -import { Connection, ConnectionSettings, ConnectionType, DBtype } from '../models/connection'; -import { AccessLevel } from '../models/user'; -import { CedarPermissionService } from './cedar-permission.service'; -import { MasterPasswordService } from './master-password.service'; -import { NotificationsService } from './notifications.service'; -import { UsersService } from './users.service'; - -interface LogParams { - connectionID: string; - tableName?: string; - userEmail?: string; - requstedPage?: number; - chunkSize?: number; -} - -type Palettes = { - primaryPalette?: string; - accentedPalette?: string; - warnPalette?: string; - whitePalette?: string; - warnDarkPalette?: string; - warningPalette?: string; - infoPalette?: string; - successPalette?: string; - alternativePalette?: string; - successDarkPalette?: string; - alternativeDarkPalette?: string; -}; -type Colors = { myColorName: string }; - -@Injectable({ - providedIn: 'root', -}) -export class ConnectionsService { - public connectionID: string | null = null; - public connectionInitialState: Connection = Object.freeze({ - id: null, - type: DBtype.MySQL, - host: '', - port: '3306', - sid: '', - username: '', - password: '', - database: '', - authSource: '', - title: '', - ssh: false, - privateSSHKey: '', - sshHost: '', - sshPort: '', - sshUsername: '', - ssl: false, - cert: '', - masterEncryption: false, - azure_encryption: false, - connectionType: ConnectionType.Direct, - signing_key: null, - }); - public connection: Connection; - public connectionAccessLevel: AccessLevel; - public groupsAccessLevel: boolean; - public currentPage: string; - public isCustomAccentedColor: boolean; - public defaultDisplayTable: string; - public ownConnections: Connection[] = null; - - private connectionNameSubject: BehaviorSubject = new BehaviorSubject('Rocketadmin'); - private connectionSigningKeySubject: BehaviorSubject = new BehaviorSubject(null); - private connectionsSubject: BehaviorSubject = new BehaviorSubject([]); - - public cast = this.connectionsSubject.asObservable(); - - invalidateConnections(): void { - this.connectionsSubject.next(null); - } - - constructor( - private _http: HttpClient, - private router: Router, - private _notifications: NotificationsService, - private _masterPassword: MasterPasswordService, - private _usersService: UsersService, - private _permissions: CedarPermissionService, - public _themeService: NgxThemeService>, - ) { - this.connection = { ...this.connectionInitialState }; - this.router = router; - this.router.events - .pipe( - filter((event): boolean => { - return event instanceof NavigationEnd; - }), - ) - .subscribe((_event: NavigationEnd): void => { - const urlConnectionID = this.router.routerState.snapshot.root.firstChild.paramMap.get('connection-id'); - this.currentPage = this.router.routerState.snapshot.root.firstChild.url[0].path; - this.setConnectionID(urlConnectionID); - this.setConnectionInfo(urlConnectionID); - if (urlConnectionID) { - this._usersService.setActiveConnection(urlConnectionID); - } - this._notifications.resetAlert(); - }); - } - - get currentConnectionID() { - return this.connectionID; - } - - get currentConnectionName() { - return this.defineConnectionTitle(this.connection); - } - - get defaultTableToOpen() { - return this.defaultDisplayTable; - } - - get currentConnection() { - return this.connection; - } - - get currentConnectionAccessLevel() { - return this.connectionAccessLevel; - } - - get currentConnectionGroupAccessLevel() { - return this.groupsAccessLevel; - } - - get currentTab() { - return this.currentPage; - } - - canEditConnection() { - return this._permissions.canI('connection:edit', 'Connection', this.connectionID)(); - } - - get visibleTabs() { - const tabs = ['dashboard', 'audit']; - if (this._permissions.canIAny('dashboard:read', 'Dashboard')()) tabs.push('dashboards'); - if (this._permissions.canIAny('group:read', 'Group')()) tabs.push('permissions'); - if (this.canEditConnection()) { - tabs.push('connection-settings', 'edit-db'); - } - return tabs; - } - - get ownConnectionsList() { - return this.ownConnections; - } - - getCurrentConnectionTitle() { - return this.connectionNameSubject.asObservable(); - } - - getCurrentConnectionSigningKey() { - return this.connectionSigningKeySubject.asObservable(); - } - - setConnectionID(id: string) { - this.connectionID = id; - } - - setConnectionInfo(id: string) { - this.defaultDisplayTable = null; - if (id) { - this.fetchConnection(id).subscribe((res) => { - this.connection = res.connection; - this.connectionAccessLevel = res.accessLevel; - this.groupsAccessLevel = res.groupManagement; - this.connectionNameSubject.next(res.connection.title || res.connection.database); - this.connectionSigningKeySubject.next(res.connection.signing_key); - if (res.connectionProperties) { - this.defaultDisplayTable = res.connectionProperties.default_showing_table; - this.isCustomAccentedColor = !!res.connectionProperties.secondary_color; - this._themeService.updateColors({ - palettes: { - primaryPalette: res.connectionProperties.primary_color, - accentedPalette: res.connectionProperties.secondary_color, - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } else { - this.isCustomAccentedColor = false; - this._themeService.updateColors({ - palettes: { - primaryPalette: '#212121', - accentedPalette: '#2563eb', - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } - }); - } else { - this.connection = { ...this.connectionInitialState }; - this.isCustomAccentedColor = false; - this._themeService.updateColors({ - palettes: { - primaryPalette: '#212121', - accentedPalette: '#2563eb', - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } - } - - isPermitted(accessLevel: AccessLevel) { - return accessLevel === 'edit' || accessLevel === 'readonly'; - } - - defineConnectionType(connection) { - if (connection.type?.startsWith('agent_')) { - connection.type = connection.type.slice(6); - connection.connectionType = ConnectionType.Agent; - } else { - connection.connectionType = ConnectionType.Direct; - } - return connection; - } - - defineConnectionTitle(connection: Connection) { - if (!connection.title && connection.masterEncryption) return 'Untitled encrypted connection'; - if (!connection.title && !connection.database) return 'Untitled connection'; - return connection.title || connection.database; - } - - private checkAndSendConversion() { - const now = new Date(); - const firstVisitTime = localStorage.getItem('first_visit_time'); - - if (!firstVisitTime) { - // First visit - record the current date - localStorage.setItem('first_visit_time', now.toISOString()); - return; - } - - const firstVisit = new Date(firstVisitTime); - const daysDifference = Math.floor((now.getTime() - firstVisit.getTime()) / (1000 * 60 * 60 * 24)); - - if (daysDifference >= 7) { - const repeatConversionSent = localStorage.getItem('repeat_conversion_sent'); - - if (!repeatConversionSent) { - localStorage.setItem('repeat_conversion_sent', 'true'); - - // Send conversion event - if (typeof window !== 'undefined' && (window as any).gtag) { - (window as any).gtag('event', 'conversion', { - send_to: 'AW-419937947/jqGyCJWo4qobEJv9nsgB', - value: 1.0, - currency: 'USD', - event_callback: () => { - console.log('Conversion event sent successfully'); - }, - }); - } - } - } - } - - fetchConnections() { - // Check for first visit and send conversion if needed - this.checkAndSendConversion(); - - return this._http.get('/connections').pipe( - map((res) => { - const connections = res.connections.map((connectionItem) => { - const connection = this.defineConnectionType(connectionItem.connection); - const displayTitle = this.defineConnectionTitle(connectionItem.connection); - return { ...connectionItem, connection, displayTitle }; - }); - this.ownConnections = connections.filter((connectionItem) => !connectionItem.connection.isTestConnection); - return connections; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - const errorDetails = err.error?.originalMessage || ''; - this._notifications.showAlert(AlertType.Error, { abstract: errorMessage, details: errorDetails }, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - return EMPTY; - }), - ); - } - - fetchConnection(id: string) { - return this._http.get(`/connection/one/${id}`).pipe( - map((res) => { - const connection = this.defineConnectionType(res.connection); - if (res.connectionProperties) { - this.defaultDisplayTable = res.connectionProperties.default_showing_table; - this._themeService.updateColors({ - palettes: { - primaryPalette: res.connectionProperties.primary_color, - accentedPalette: res.connectionProperties.secondary_color, - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } else { - this.defaultDisplayTable = null; - this._themeService.updateColors({ - palettes: { - primaryPalette: '#212121', - accentedPalette: '#2563eb', - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } - return { ...res, connection }; - }), - catchError((err) => { - console.log(err); - if (err.error?.type === 'no_master_key' && this.router.url !== '/connections-list') { - this._masterPassword.showMasterPasswordDialog(); - } - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(errorMessage); - return EMPTY; - }), - ); - } - - testConnection(connectionID: string, connection: Connection) { - let dbCredentials; - dbCredentials = { - ...connection, - port: parseInt(connection.port, 10), - sshPort: parseInt(connection.sshPort, 10), - }; - - if (connection.connectionType === 'agent') { - dbCredentials.type = `agent_${dbCredentials.type}`; - } - - return this._http - .post(`/connection/test`, dbCredentials, { - params: { - ...(connectionID ? { connectionId: connectionID } : {}), - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - const errorDetails = err.error?.originalMessage || ''; - this._notifications.showAlert(AlertType.Error, { abstract: errorMessage, details: errorDetails }, []); - return EMPTY; - }), - ); - } - - createConnection(connection: Connection, masterKey: string) { - let dbCredentials; - dbCredentials = { - ...connection, - port: parseInt(connection.port, 10), - sshPort: parseInt(connection.sshPort, 10), - }; - - if (connection.connectionType === 'agent') { - dbCredentials.type = `agent_${dbCredentials.type}`; - } - - return this._http - .post('/connection', dbCredentials, { - headers: masterKey - ? { - masterpwd: masterKey, - } - : {}, - }) - .pipe( - map((res: any) => { - this.connectionsSubject.next(null); - this._masterPassword.checkMasterPassword(connection.masterEncryption, res.id, masterKey); - this._notifications.showSuccessSnackbar('Connection was added successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - const errorDetails = err.error?.originalMessage || ''; - this._notifications.showAlert(AlertType.Error, { abstract: errorMessage, details: errorDetails }, []); - return throwError(() => errorMessage); - }), - ); - } - - updateConnection(connection: Connection, masterKey: string) { - let dbCredentials; - dbCredentials = { - ...connection, - port: parseInt(connection.port, 10), - sshPort: parseInt(connection.sshPort, 10), - }; - - if (connection.connectionType === 'agent') { - dbCredentials.type = `agent_${dbCredentials.type}`; - } - - return this._http - .put(`/connection/${connection.id}`, dbCredentials, { - headers: masterKey - ? { - masterpwd: masterKey, - } - : {}, - }) - .pipe( - map((res) => { - this._masterPassword.checkMasterPassword(connection.masterEncryption, connection.id, masterKey); - this._notifications.showSuccessSnackbar('Connection has been updated successfully.'); - this.connectionsSubject.next(null); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - const errorDetails = err.error?.originalMessage || ''; - this._notifications.showAlert(AlertType.Error, { abstract: errorMessage, details: errorDetails }, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - // this._notifications.showErrorSnackbar(`${err.error.message}. Connection has not been updated.`); - console.log('updateConnection catchError'); - return throwError(() => new Error(errorMessage)); - }), - ); - } - - deleteConnection(id: string, metadata) { - return this._http.put(`/connection/delete/${id}`, metadata).pipe( - map(() => { - this.connectionsSubject.next(null); - this._notifications.showSuccessSnackbar('Connection has been deleted successfully.'); - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(errorMessage); - return EMPTY; - }), - ); - } - - fetchAuditLog({ connectionID, tableName, userEmail, requstedPage, chunkSize }: LogParams) { - if (tableName === 'showAll') tableName = null; - if (userEmail === 'showAll') userEmail = null; - return this._http - .get(`/logs/${connectionID}`, { - params: { - page: requstedPage.toString(), - perPage: chunkSize.toString(), - ...(tableName ? { tableName } : {}), - ...(userEmail ? { email: userEmail } : {}), - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(errorMessage); - return EMPTY; - }), - ); - } - - getConnectionSettings(connectionID: string) { - return this._http.get(`/connection/properties/${connectionID}`).pipe( - map((res: any) => { - if (res) { - this._themeService.updateColors({ - palettes: { - primaryPalette: res.primary_color, - accentedPalette: res.secondary_color, - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - }); - } - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } - - createConnectionSettings(connectionID: string, settings: ConnectionSettings) { - return this._http.post(`/connection/properties/${connectionID}`, settings).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Connection settings has been created successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } - - updateConnectionSettings(connectionID: string, settings: ConnectionSettings) { - return this._http.put(`/connection/properties/${connectionID}`, settings).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Connection settings has been updated successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } - - updateConnectionTitle(connectionID: string, title: string) { - return this._http.put(`/connection/title/${connectionID}`, { title }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Connection title has been updated successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } - - deleteConnectionSettings(connectionID: string) { - return this._http.delete(`/connection/properties/${connectionID}`).pipe( - map(() => { - this._notifications.showSuccessSnackbar('Connection settings has been removed successfully.'); - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/dashboards.service.ts b/frontend/src/app/services/dashboards.service.ts deleted file mode 100644 index 7d8731daa..000000000 --- a/frontend/src/app/services/dashboards.service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { HttpResourceRef } from '@angular/common/http'; -import { computed, Injectable, inject, signal } from '@angular/core'; -import { - CreateDashboardPayload, - CreateWidgetPayload, - Dashboard, - DashboardWidget, - UpdateDashboardPayload, - UpdateWidgetPayload, -} from '../models/dashboard'; -import { GeneratedPanelWithPosition } from '../models/saved-query'; -import { ApiService } from './api.service'; - -export type DashboardUpdateEvent = 'created' | 'updated' | 'deleted' | ''; - -@Injectable({ - providedIn: 'root', -}) -export class DashboardsService { - private _api = inject(ApiService); - - private _dashboardsUpdated = signal(''); - public readonly dashboardsUpdated = this._dashboardsUpdated.asReadonly(); - - // Active connection for reactive fetching of dashboards list - private _activeConnectionId = signal(null); - - // Active dashboard for reactive fetching of single dashboard - private _activeDashboardId = signal(null); - - // Resource for dashboards list - private _dashboardsResource: HttpResourceRef = this._api.resource( - () => { - const connectionId = this._activeConnectionId(); - if (!connectionId) return undefined; - return `/dashboards/${connectionId}`; - }, - { errorMessage: 'Failed to fetch dashboards' }, - ); - - // Resource for single dashboard with widgets - private _dashboardResource: HttpResourceRef = this._api.resource( - () => { - const connectionId = this._activeConnectionId(); - const dashboardId = this._activeDashboardId(); - if (!connectionId || !dashboardId) return undefined; - return `/dashboard/${dashboardId}/${connectionId}`; - }, - { errorMessage: 'Failed to fetch dashboard' }, - ); - - // Computed signals for convenient access - public readonly dashboards = computed(() => this._dashboardsResource.value() ?? []); - public readonly dashboardsLoading = computed(() => this._dashboardsResource.isLoading()); - public readonly dashboardsError = computed(() => this._dashboardsResource.error() as Error | null); - - public readonly dashboard = computed(() => this._dashboardResource.value() ?? null); - public readonly dashboardLoading = computed(() => this._dashboardResource.isLoading()); - public readonly dashboardError = computed(() => this._dashboardResource.error() as Error | null); - - // Methods to control resource - setActiveConnection(connectionId: string): void { - this._activeConnectionId.set(connectionId); - } - - setActiveDashboard(dashboardId: string | null): void { - this._activeDashboardId.set(dashboardId); - } - - refreshDashboards(): void { - this._dashboardsResource.reload(); - } - - clearDashboardsUpdated(): void { - this._dashboardsUpdated.set(''); - } - - refreshDashboard(): void { - this._dashboardResource.reload(); - } - - // Dashboard CRUD operations - async createDashboard(connectionId: string, payload: CreateDashboardPayload): Promise { - const dashboard = await this._api.post(`/dashboards/${connectionId}`, payload, { - successMessage: 'Dashboard created successfully', - }); - if (dashboard) this._dashboardsUpdated.set('created'); - return dashboard; - } - - async updateDashboard( - connectionId: string, - dashboardId: string, - payload: UpdateDashboardPayload, - ): Promise { - const dashboard = await this._api.put(`/dashboard/${dashboardId}/${connectionId}`, payload, { - successMessage: 'Dashboard updated successfully', - }); - if (dashboard) this._dashboardsUpdated.set('updated'); - return dashboard; - } - - async deleteDashboard(connectionId: string, dashboardId: string): Promise { - const dashboard = await this._api.delete(`/dashboard/${dashboardId}/${connectionId}`, { - successMessage: 'Dashboard deleted successfully', - }); - if (dashboard) this._dashboardsUpdated.set('deleted'); - return dashboard; - } - - // Widget CRUD operations - async createWidget( - connectionId: string, - dashboardId: string, - payload: CreateWidgetPayload, - ): Promise { - const widget = await this._api.post(`/dashboard/${dashboardId}/widget/${connectionId}`, payload, { - successMessage: 'Widget created successfully', - }); - if (widget) this._dashboardsUpdated.set('updated'); - return widget; - } - - async updateWidget( - connectionId: string, - dashboardId: string, - widgetId: string, - payload: UpdateWidgetPayload, - ): Promise { - return this._api.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload); - } - - async updateWidgetPosition( - connectionId: string, - dashboardId: string, - widgetId: string, - payload: Pick, - ): Promise { - return this._api.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload); - } - - async generateWidgetWithAi( - connectionId: string, - payload: { chart_description: string; name?: string }, - ): Promise { - return this._api.post(`/widget/generate/${connectionId}`, payload); - } - - async deleteWidget(connectionId: string, dashboardId: string, widgetId: string): Promise { - const widget = await this._api.delete( - `/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, - { - successMessage: 'Widget deleted successfully', - }, - ); - if (widget) this._dashboardsUpdated.set('updated'); - return widget; - } -} diff --git a/frontend/src/app/services/master-password.service.spec.ts b/frontend/src/app/services/master-password.service.spec.ts deleted file mode 100644 index 34a2077ec..000000000 --- a/frontend/src/app/services/master-password.service.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { provideRouter } from '@angular/router'; -import { MasterPasswordDialogComponent } from '../components/master-password-dialog/master-password-dialog.component'; - -import { MasterPasswordService } from './master-password.service'; - -describe('MasterPasswordService', () => { - let service: MasterPasswordService; - let dialog: MatDialog; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatDialogModule], - providers: [provideHttpClient(), provideRouter([]), { provide: MatDialogRef, useValue: { close: vi.fn() } }], - }); - - service = TestBed.inject(MasterPasswordService); - dialog = TestBed.inject(MatDialog); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should show Master password dialog', () => { - const fakeDialog = vi - .spyOn(dialog, 'open') - .mockReturnValue({ afterClosed: () => ({ subscribe: () => {} }) } as any); - service.showMasterPasswordDialog(); - expect(fakeDialog).toHaveBeenCalledWith(MasterPasswordDialogComponent, { - width: '24em', - disableClose: true, - }); - }); - - it('should write master key in localstorage if masterEncryption is turned on', () => { - const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); - - service.checkMasterPassword(true, '12345678', 'abcd-0987654321'); - - expect(setItemSpy).toHaveBeenCalledWith('12345678__masterKey', 'abcd-0987654321'); - }); - - it('should remove master key in localstorage if masterEncryption is turned off', () => { - const removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem'); - - service.checkMasterPassword(false, '12345678', 'abcd-0987654321'); - - expect(removeItemSpy).toHaveBeenCalledWith('12345678__masterKey'); - }); -}); diff --git a/frontend/src/app/services/master-password.service.ts b/frontend/src/app/services/master-password.service.ts deleted file mode 100644 index e646829dd..000000000 --- a/frontend/src/app/services/master-password.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MasterPasswordDialogComponent } from '../components/master-password-dialog/master-password-dialog.component'; - -@Injectable({ - providedIn: 'root', -}) -export class MasterPasswordService { - // private showMasterPasswordDialogRef: MatDialogRef; - - constructor(public dialog: MatDialog) {} - - showMasterPasswordDialog() { - this.dialog.open(MasterPasswordDialogComponent, { - width: '24em', - disableClose: true, - }); - } - - checkMasterPassword(isEncryptionEnabled: boolean, connectionID: string, masterKey: string) { - if (isEncryptionEnabled) { - localStorage.setItem(`${connectionID}__masterKey`, masterKey); - } else { - localStorage.removeItem(`${connectionID}__masterKey`); - } - } -} diff --git a/frontend/src/app/services/notifications.service.spec.ts b/frontend/src/app/services/notifications.service.spec.ts deleted file mode 100644 index e24be5c6d..000000000 --- a/frontend/src/app/services/notifications.service.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { Alert, AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; - -describe('NotificationsService', () => { - let service: NotificationsService; - let snackBar: MatSnackBar; - - const alert: Alert = { - id: 0, - type: AlertType.Error, - message: 'Error message', - actions: [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - }, - ], - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - }); - - service = TestBed.inject(NotificationsService); - snackBar = TestBed.inject(MatSnackBar); - service.idCounter = 0; - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should show ErrorSnackbar', () => { - const fakeSnackBar = vi.spyOn(snackBar, 'open'); - service.showErrorSnackbar('Error message.'); - expect(fakeSnackBar).toHaveBeenCalledWith( - 'Error message.', - 'Dismiss', - Object({ - duration: 10000, - horizontalPosition: 'left', - }), - ); - }); - - it('should show SuccessSnackbar', () => { - const fakeSnackBar = vi.spyOn(snackBar, 'open'); - service.showSuccessSnackbar('Success message.'); - expect(fakeSnackBar).toHaveBeenCalledWith( - 'Success message.', - null, - Object({ - duration: 2500, - horizontalPosition: 'left', - verticalPosition: 'bottom', - }), - ); - }); - - it('should get alert', () => { - service.alert = alert; - expect(service.currentAlert).toEqual(alert); - }); - - it('should show new alert', () => { - service.alert = alert; - service.showAlert(AlertType.Error, 'Error message 2', [ - { - type: AlertActionType.Button, - caption: 'Dissmis', - }, - ]); - - expect(service.alert).toEqual({ - id: 1, - type: AlertType.Error, - message: 'Error message 2', - actions: [ - { - type: AlertActionType.Button, - caption: 'Dissmis', - }, - ], - }); - }); - - it('should dissmis alert', () => { - service.alert = alert; - service.dismissAlert(); - - expect(service.alert).toBeNull(); - }); - - it('should reset alert', () => { - service.alert = alert; - service.resetAlert(); - - expect(service.alert).toBeNull(); - }); -}); diff --git a/frontend/src/app/services/notifications.service.ts b/frontend/src/app/services/notifications.service.ts deleted file mode 100644 index b04d1fa82..000000000 --- a/frontend/src/app/services/notifications.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Injectable } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Alert, AlertAction, AlertType, ServerError } from '../models/alert'; - -@Injectable({ - providedIn: 'root', -}) -export class NotificationsService { - public alert: Alert; - public idCounter = 0; - - constructor(private snackBar: MatSnackBar) {} - - showErrorSnackbar(message: string) { - this.snackBar.open(message, 'Dismiss', { - duration: 10000, - horizontalPosition: 'left', - }); - } - - showSuccessSnackbar(message: string, verticalPosition: 'top' | 'bottom' = 'bottom') { - this.snackBar.open(message, null, { - duration: 2500, - horizontalPosition: 'left', - verticalPosition, - }); - } - - get currentAlert() { - return this.alert; - } - - showAlert(type: AlertType, message: ServerError | string, actions: AlertAction[]) { - this.idCounter++; - this.alert = { - id: this.idCounter, - type: type, - message: message, - actions: actions, - }; - // window.scrollTo(0, 0); - } - - dismissAlert() { - this.alert = null; - } - - resetAlert() { - this.alert = null; - } -} diff --git a/frontend/src/app/services/posthog.service.ts b/frontend/src/app/services/posthog.service.ts deleted file mode 100644 index ea15ccece..000000000 --- a/frontend/src/app/services/posthog.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DestroyRef, Injectable, NgZone } from '@angular/core'; -import { Router } from '@angular/router'; -import posthog from 'posthog-js'; -import { environment } from '../../environments/environment'; - -@Injectable({ providedIn: 'root' }) -export class PosthogService { - constructor( - private ngZone: NgZone, - private router: Router, - private destroyRef: DestroyRef, - ) { - if ((environment as any).saas && environment.production) { - this.initPostHog(); - } - } - - private initPostHog() { - this.ngZone.runOutsideAngular(() => { - posthog.init('phc_VPnWHIMj9UjhRLPr7shATjgL0J4KrWWOHkK3JwZbnkw', { - api_host: 'https://us.i.posthog.com', - defaults: '2025-11-30', - }); - }); - } -} diff --git a/frontend/src/app/services/s3.service.spec.ts b/frontend/src/app/services/s3.service.spec.ts deleted file mode 100644 index b6322e489..000000000 --- a/frontend/src/app/services/s3.service.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { NotificationsService } from './notifications.service'; -import { S3Service } from './s3.service'; - -describe('S3Service', () => { - let service: S3Service; - let httpMock: HttpTestingController; - let fakeNotifications: { showAlert: ReturnType; dismissAlert: ReturnType }; - - const mockFileUrlResponse = { - url: 'https://s3.amazonaws.com/bucket/file.pdf?signature=abc123', - key: 'prefix/file.pdf', - expiresIn: 3600, - }; - - const mockUploadUrlResponse = { - uploadUrl: 'https://s3.amazonaws.com/bucket/prefix/newfile.pdf?signature=xyz789', - key: 'prefix/newfile.pdf', - expiresIn: 3600, - previewUrl: 'https://s3.amazonaws.com/bucket/prefix/newfile.pdf?preview=true', - }; - - const fakeError = { - message: 'Something went wrong', - statusCode: 400, - }; - - beforeEach(() => { - fakeNotifications = { - showAlert: vi.fn(), - dismissAlert: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - S3Service, - { provide: NotificationsService, useValue: fakeNotifications }, - ], - }); - - service = TestBed.inject(S3Service); - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('getFileUrl', () => { - const connectionId = 'conn-123'; - const tableName = 'users'; - const fieldName = 'avatar'; - const rowPrimaryKey = { id: 1 }; - - it('should fetch file URL successfully', async () => { - const resultPromise = service.getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey); - - const req = httpMock.expectOne( - (request) => - request.url === `/s3/file/${connectionId}` && - request.params.get('tableName') === tableName && - request.params.get('fieldName') === fieldName && - request.params.get('rowPrimaryKey') === JSON.stringify(rowPrimaryKey), - ); - expect(req.request.method).toBe('GET'); - req.flush(mockFileUrlResponse); - - const result = await resultPromise; - expect(result).toEqual(mockFileUrlResponse); - }); - - it('should handle complex primary key', async () => { - const complexPrimaryKey = { user_id: 1, org_id: 'abc' }; - - const resultPromise = service.getFileUrl(connectionId, tableName, fieldName, complexPrimaryKey); - - const req = httpMock.expectOne( - (request) => - request.url === `/s3/file/${connectionId}` && - request.params.get('rowPrimaryKey') === JSON.stringify(complexPrimaryKey), - ); - req.flush(mockFileUrlResponse); - - const result = await resultPromise; - expect(result).toEqual(mockFileUrlResponse); - }); - - it('should show error alert on failure', async () => { - const resultPromise = service.getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey); - - const req = httpMock.expectOne((request) => request.url === `/s3/file/${connectionId}`); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - const result = await resultPromise; - expect(result).toBeNull(); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - abstract: 'Failed to get S3 file URL', - details: fakeError.message, - }), - expect.any(Array), - ); - }); - - it('should return null on error', async () => { - const resultPromise = service.getFileUrl(connectionId, tableName, fieldName, rowPrimaryKey); - - const req = httpMock.expectOne((request) => request.url === `/s3/file/${connectionId}`); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - const result = await resultPromise; - expect(result).toBeNull(); - }); - }); - - describe('getUploadUrl', () => { - const connectionId = 'conn-123'; - const tableName = 'users'; - const fieldName = 'avatar'; - const filename = 'document.pdf'; - const contentType = 'application/pdf'; - - it('should fetch upload URL successfully', async () => { - const resultPromise = service.getUploadUrl(connectionId, tableName, fieldName, filename, contentType); - - const req = httpMock.expectOne( - (request) => - request.url === `/s3/upload-url/${connectionId}` && - request.params.get('tableName') === tableName && - request.params.get('fieldName') === fieldName, - ); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ filename, contentType }); - req.flush(mockUploadUrlResponse); - - const result = await resultPromise; - expect(result).toEqual(mockUploadUrlResponse); - }); - - it('should handle image upload', async () => { - const imageFilename = 'photo.jpg'; - const imageContentType = 'image/jpeg'; - - const resultPromise = service.getUploadUrl(connectionId, tableName, fieldName, imageFilename, imageContentType); - - const req = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - expect(req.request.body).toEqual({ - filename: imageFilename, - contentType: imageContentType, - }); - req.flush(mockUploadUrlResponse); - - await resultPromise; - }); - - it('should show error alert on failure', async () => { - const resultPromise = service.getUploadUrl(connectionId, tableName, fieldName, filename, contentType); - - const req = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - const result = await resultPromise; - expect(result).toBeNull(); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - abstract: 'Failed to get upload URL', - details: fakeError.message, - }), - expect.any(Array), - ); - }); - - it('should return null on error', async () => { - const resultPromise = service.getUploadUrl(connectionId, tableName, fieldName, filename, contentType); - - const req = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - const result = await resultPromise; - expect(result).toBeNull(); - }); - }); - - describe('uploadToS3', () => { - const uploadUrl = 'https://s3.amazonaws.com/bucket/file.pdf?signature=abc123'; - - it('should upload file to S3 successfully', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadToS3(uploadUrl, file); - - const req = httpMock.expectOne(uploadUrl); - expect(req.request.method).toBe('PUT'); - expect(req.request.headers.get('Content-Type')).toBe('application/pdf'); - expect(req.request.body).toBe(file); - req.flush(null); - - const result = await resultPromise; - expect(result).toBe(true); - }); - - it('should upload image file with correct content type', async () => { - const file = new File(['image data'], 'photo.jpg', { - type: 'image/jpeg', - }); - - const resultPromise = service.uploadToS3(uploadUrl, file); - - const req = httpMock.expectOne(uploadUrl); - expect(req.request.headers.get('Content-Type')).toBe('image/jpeg'); - req.flush(null); - - await resultPromise; - }); - - it('should show error alert on upload failure', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadToS3(uploadUrl, file); - - const req = httpMock.expectOne(uploadUrl); - req.flush(null, { status: 500, statusText: 'Internal Server Error' }); - - const result = await resultPromise; - expect(result).toBe(false); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - abstract: 'File upload failed', - }), - expect.any(Array), - ); - }); - - it('should return false on error', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadToS3(uploadUrl, file); - - const req = httpMock.expectOne(uploadUrl); - req.flush(null, { status: 500, statusText: 'Internal Server Error' }); - - const result = await resultPromise; - expect(result).toBe(false); - }); - }); - - describe('uploadFile', () => { - const connectionId = 'conn-123'; - const tableName = 'users'; - const fieldName = 'avatar'; - - it('should get upload URL and upload file successfully', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadFile(connectionId, tableName, fieldName, file); - - // First request: get upload URL - const uploadUrlReq = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - expect(uploadUrlReq.request.method).toBe('POST'); - expect(uploadUrlReq.request.body).toEqual({ - filename: 'test.pdf', - contentType: 'application/pdf', - }); - uploadUrlReq.flush(mockUploadUrlResponse); - - // Allow the first promise to resolve so the second request starts - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Second request: upload to S3 - const uploadReq = httpMock.expectOne(mockUploadUrlResponse.uploadUrl); - expect(uploadReq.request.method).toBe('PUT'); - expect(uploadReq.request.body).toBe(file); - uploadReq.flush(null); - - const result = await resultPromise; - expect(result).toEqual({ - key: mockUploadUrlResponse.key, - previewUrl: mockUploadUrlResponse.previewUrl, - }); - }); - - it('should return null if getting upload URL fails', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadFile(connectionId, tableName, fieldName, file); - - const uploadUrlReq = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - uploadUrlReq.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - const result = await resultPromise; - expect(result).toBeNull(); - expect(fakeNotifications.showAlert).toHaveBeenCalled(); - }); - - it('should return null if S3 upload fails', async () => { - const file = new File(['test content'], 'test.pdf', { - type: 'application/pdf', - }); - - const resultPromise = service.uploadFile(connectionId, tableName, fieldName, file); - - // First request succeeds - const uploadUrlReq = httpMock.expectOne((request) => request.url === `/s3/upload-url/${connectionId}`); - uploadUrlReq.flush(mockUploadUrlResponse); - - // Allow the first promise to resolve so the second request starts - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Second request fails - const uploadReq = httpMock.expectOne(mockUploadUrlResponse.uploadUrl); - uploadReq.flush(null, { status: 500, statusText: 'Internal Server Error' }); - - const result = await resultPromise; - expect(result).toBeNull(); - expect(fakeNotifications.showAlert).toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/services/s3.service.ts b/frontend/src/app/services/s3.service.ts deleted file mode 100644 index 459d921a6..000000000 --- a/frontend/src/app/services/s3.service.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; - -interface BucketFileUrlResponse { - url: string; - key: string; - expiresIn: number; -} - -interface BucketUploadUrlResponse { - uploadUrl: string; - key: string; - expiresIn: number; - previewUrl: string; -} - -@Injectable({ - providedIn: 'root', -}) -export class S3Service { - private readonly http = inject(HttpClient); - private readonly notifications = inject(NotificationsService); - - async getFileUrl( - connectionId: string, - tableName: string, - fieldName: string, - rowPrimaryKey: Record, - ): Promise { - try { - return await firstValueFrom( - this.http.get(`/s3/file/${connectionId}`, { - params: { - tableName, - fieldName, - rowPrimaryKey: JSON.stringify(rowPrimaryKey), - }, - }), - ); - } catch (err: any) { - this._showError('Failed to get S3 file URL', err.error?.message); - return null; - } - } - - async getUploadUrl( - connectionId: string, - tableName: string, - fieldName: string, - filename: string, - contentType: string, - ): Promise { - try { - return await firstValueFrom( - this.http.post( - `/s3/upload-url/${connectionId}`, - { filename, contentType }, - { params: { tableName, fieldName } }, - ), - ); - } catch (err: any) { - this._showError('Failed to get upload URL', err.error?.message); - return null; - } - } - - async uploadToS3(uploadUrl: string, file: File): Promise { - try { - await firstValueFrom( - this.http.put(uploadUrl, file, { - headers: { 'Content-Type': file.type }, - }), - ); - return true; - } catch (err: any) { - this._showError('File upload failed', err.message); - return false; - } - } - - /** - * Combined upload flow: gets presigned URL and uploads file in one operation. - * Returns the S3 key and preview URL on success. - */ - async uploadFile( - connectionId: string, - tableName: string, - fieldName: string, - file: File, - ): Promise<{ key: string; previewUrl: string } | null> { - try { - const response = await firstValueFrom( - this.http.post( - `/s3/upload-url/${connectionId}`, - { filename: file.name, contentType: file.type }, - { params: { tableName, fieldName } }, - ), - ); - - await firstValueFrom( - this.http.put(response.uploadUrl, file, { - headers: { 'Content-Type': file.type }, - }), - ); - - return { key: response.key, previewUrl: response.previewUrl }; - } catch (err: any) { - this._showError('File upload failed', err.error?.message || err.message); - return null; - } - } - - private _showError(abstract: string, details?: string): void { - this.notifications.showAlert(AlertType.Error, { abstract, details }, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this.notifications.dismissAlert(), - }, - ]); - } -} diff --git a/frontend/src/app/services/saved-queries.service.ts b/frontend/src/app/services/saved-queries.service.ts deleted file mode 100644 index d6782ead4..000000000 --- a/frontend/src/app/services/saved-queries.service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { HttpResourceRef } from '@angular/common/http'; -import { computed, Injectable, inject, signal } from '@angular/core'; -import { - CreateSavedQueryPayload, - QueryExecutionResult, - SavedQuery, - TestQueryPayload, - TestQueryResult, - UpdateSavedQueryPayload, -} from '../models/saved-query'; -import { ApiService } from './api.service'; - -export type QueryUpdateEvent = 'created' | 'updated' | 'deleted' | ''; - -@Injectable({ - providedIn: 'root', -}) -export class SavedQueriesService { - private _api = inject(ApiService); - - private _queriesUpdated = signal(''); - public readonly queriesUpdated = this._queriesUpdated.asReadonly(); - - // Active connection for reactive fetching - private _activeConnectionId = signal(null); - - // Resource for saved queries - private _savedQueriesResource: HttpResourceRef = this._api.resource( - () => { - const connectionId = this._activeConnectionId(); - if (!connectionId) return undefined; - return `/connection/${connectionId}/saved-queries`; - }, - { errorMessage: 'Failed to fetch saved queries' }, - ); - - // Computed signals for convenient access - public readonly savedQueries = computed(() => this._savedQueriesResource.value() ?? []); - public readonly savedQueriesLoading = computed(() => this._savedQueriesResource.isLoading()); - public readonly savedQueriesError = computed(() => this._savedQueriesResource.error() as Error | null); - - // Methods to control resource - setActiveConnection(connectionId: string): void { - this._activeConnectionId.set(connectionId); - } - - refreshSavedQueries(): void { - this._savedQueriesResource.reload(); - } - - async fetchSavedQuery(connectionId: string, queryId: string): Promise { - return this._api.get(`/connection/${connectionId}/saved-query/${queryId}`); - } - - async createSavedQuery(connectionId: string, payload: CreateSavedQueryPayload): Promise { - const query = await this._api.post(`/connection/${connectionId}/saved-query`, payload, { - successMessage: 'Saved query created successfully', - }); - if (query) this._queriesUpdated.set('created'); - return query; - } - - async updateSavedQuery( - connectionId: string, - queryId: string, - payload: UpdateSavedQueryPayload, - ): Promise { - const query = await this._api.put(`/connection/${connectionId}/saved-query/${queryId}`, payload, { - successMessage: 'Saved query updated successfully', - }); - if (query) this._queriesUpdated.set('updated'); - return query; - } - - async deleteSavedQuery(connectionId: string, queryId: string): Promise { - const query = await this._api.delete(`/connection/${connectionId}/saved-query/${queryId}`, { - successMessage: 'Saved query deleted successfully', - }); - if (query) this._queriesUpdated.set('deleted'); - return query; - } - - async executeSavedQuery( - connectionId: string, - queryId: string, - tableName?: string, - ): Promise { - return this._api.post( - `/connection/${connectionId}/saved-query/${queryId}/execute`, - {}, - tableName ? { params: { tableName } } : undefined, - ); - } - - async testQuery(connectionId: string, payload: TestQueryPayload): Promise { - return this._api.post(`/connection/${connectionId}/query/test`, payload); - } -} diff --git a/frontend/src/app/services/secrets.service.spec.ts b/frontend/src/app/services/secrets.service.spec.ts deleted file mode 100644 index e60f9046e..000000000 --- a/frontend/src/app/services/secrets.service.spec.ts +++ /dev/null @@ -1,517 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { - AuditLogResponse, - CreateSecretPayload, - DeleteSecretResponse, - Secret, - SecretListResponse, - UpdateSecretPayload, -} from '../models/secret'; -import { NotificationsService } from './notifications.service'; -import { SecretsService } from './secrets.service'; - -describe('SecretsService', () => { - let service: SecretsService; - let httpMock: HttpTestingController; - let fakeNotifications: { showErrorSnackbar: ReturnType; showSuccessSnackbar: ReturnType }; - - const mockSecret: Secret = { - id: '1', - slug: 'test-secret', - companyId: 'company-1', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - masterEncryption: false, - }; - - const mockSecretWithExpiration: Secret = { - ...mockSecret, - expiresAt: '2025-01-01T00:00:00Z', - }; - - const mockSecretListResponse: SecretListResponse = { - data: [mockSecret, mockSecretWithExpiration], - pagination: { - total: 2, - currentPage: 1, - perPage: 20, - lastPage: 1, - }, - }; - - const mockAuditLogResponse: AuditLogResponse = { - data: [ - { - id: '1', - action: 'create', - user: { id: 'user-1', email: 'user@example.com' }, - accessedAt: '2024-01-01T00:00:00Z', - success: true, - }, - { - id: '2', - action: 'view', - user: { id: 'user-1', email: 'user@example.com' }, - accessedAt: '2024-01-02T00:00:00Z', - success: true, - }, - ], - pagination: { - total: 2, - currentPage: 1, - perPage: 50, - lastPage: 1, - }, - }; - - const mockDeleteResponse: DeleteSecretResponse = { - message: 'Secret deleted successfully', - deletedAt: '2024-01-01T00:00:00Z', - }; - - const fakeError = { - message: 'Something went wrong', - statusCode: 400, - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - SecretsService, - { provide: NotificationsService, useValue: fakeNotifications }, - ], - }); - - service = TestBed.inject(SecretsService); - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('fetchSecrets', () => { - it('should fetch secrets with default pagination', () => { - let result: SecretListResponse | undefined; - - service.fetchSecrets().subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets?page=1&limit=20'); - expect(req.request.method).toBe('GET'); - req.flush(mockSecretListResponse); - - expect(result).toEqual(mockSecretListResponse); - }); - - it('should fetch secrets with custom pagination', () => { - let result: SecretListResponse | undefined; - - service.fetchSecrets(2, 10).subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets?page=2&limit=10'); - expect(req.request.method).toBe('GET'); - req.flush(mockSecretListResponse); - - expect(result).toEqual(mockSecretListResponse); - }); - - it('should fetch secrets with search query', () => { - let result: SecretListResponse | undefined; - - service.fetchSecrets(1, 20, 'api-key').subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets?page=1&limit=20&search=api-key'); - expect(req.request.method).toBe('GET'); - req.flush(mockSecretListResponse); - - expect(result).toEqual(mockSecretListResponse); - }); - - it('should show error snackbar on fetch failure', async () => { - const promise = service.fetchSecrets().toPromise(); - - const req = httpMock.expectOne('/secrets?page=1&limit=20'); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should show default error message when error has no message', async () => { - const promise = service.fetchSecrets().toPromise(); - - const req = httpMock.expectOne('/secrets?page=1&limit=20'); - req.flush({}, { status: 500, statusText: 'Internal Server Error' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to fetch secrets'); - }); - }); - - describe('createSecret', () => { - const createPayload: CreateSecretPayload = { - slug: 'new-secret', - value: 'secret-value', - }; - - it('should create a secret successfully', () => { - let result: Secret | undefined; - - service.createSecret(createPayload).subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets'); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(createPayload); - req.flush(mockSecret); - - expect(result).toEqual(mockSecret); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret created successfully'); - }); - - it('should create a secret with expiration', () => { - const payloadWithExpiration: CreateSecretPayload = { - ...createPayload, - expiresAt: '2025-01-01T00:00:00Z', - }; - - service.createSecret(payloadWithExpiration).subscribe(); - - const req = httpMock.expectOne('/secrets'); - expect(req.request.body).toEqual(payloadWithExpiration); - req.flush(mockSecretWithExpiration); - }); - - it('should create a secret with master encryption', () => { - const payloadWithEncryption: CreateSecretPayload = { - ...createPayload, - masterEncryption: true, - masterPassword: 'my-master-password', - }; - - service.createSecret(payloadWithEncryption).subscribe(); - - const req = httpMock.expectOne('/secrets'); - expect(req.request.body).toEqual(payloadWithEncryption); - req.flush({ ...mockSecret, masterEncryption: true }); - }); - - it('should emit secretsUpdated on successful creation', () => { - let updateAction: string | undefined; - service.cast.subscribe((action) => { - updateAction = action; - }); - - service.createSecret(createPayload).subscribe(); - - const req = httpMock.expectOne('/secrets'); - req.flush(mockSecret); - - expect(updateAction).toBe('created'); - }); - - it('should show conflict error when slug already exists', async () => { - const promise = service.createSecret(createPayload).toPromise(); - - const req = httpMock.expectOne('/secrets'); - req.flush({ message: 'Conflict' }, { status: 409, statusText: 'Conflict' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('A secret with this slug already exists'); - }); - - it('should show generic error on other failures', async () => { - const promise = service.createSecret(createPayload).toPromise(); - - const req = httpMock.expectOne('/secrets'); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - }); - - describe('updateSecret', () => { - const updatePayload: UpdateSecretPayload = { - value: 'new-value', - }; - - it('should update a secret successfully', () => { - let result: Secret | undefined; - - service.updateSecret('test-secret', updatePayload).subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual(updatePayload); - req.flush(mockSecret); - - expect(result).toEqual(mockSecret); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret updated successfully'); - }); - - it('should update a secret with new expiration', () => { - const payloadWithExpiration: UpdateSecretPayload = { - ...updatePayload, - expiresAt: '2026-01-01T00:00:00Z', - }; - - service.updateSecret('test-secret', payloadWithExpiration).subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.body).toEqual(payloadWithExpiration); - req.flush(mockSecretWithExpiration); - }); - - it('should clear expiration when expiresAt is null', () => { - const payloadClearExpiration: UpdateSecretPayload = { - ...updatePayload, - expiresAt: null, - }; - - service.updateSecret('test-secret', payloadClearExpiration).subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.body).toEqual(payloadClearExpiration); - req.flush(mockSecret); - }); - - it('should send master password in header when provided', () => { - service.updateSecret('test-secret', updatePayload, 'master-password-123').subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.headers.get('masterpwd')).toBe('master-password-123'); - req.flush(mockSecret); - }); - - it('should not send master password header when not provided', () => { - service.updateSecret('test-secret', updatePayload).subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.headers.has('masterpwd')).toBe(false); - req.flush(mockSecret); - }); - - it('should emit secretsUpdated on successful update', () => { - let updateAction: string | undefined; - service.cast.subscribe((action) => { - updateAction = action; - }); - - service.updateSecret('test-secret', updatePayload).subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush(mockSecret); - - expect(updateAction).toBe('updated'); - }); - - it('should throw error on 403 (invalid master password)', async () => { - let errorThrown = false; - - service.updateSecret('test-secret', updatePayload, 'wrong-password').subscribe({ - error: (err) => { - errorThrown = true; - expect(err.status).toBe(403); - }, - }); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush({ message: 'Invalid master password' }, { status: 403, statusText: 'Forbidden' }); - - expect(errorThrown).toBe(true); - }); - - it('should show error for expired secret (410)', async () => { - const promise = service.updateSecret('test-secret', updatePayload).toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush({ message: 'Secret expired' }, { status: 410, statusText: 'Gone' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Cannot update an expired secret'); - }); - - it('should show generic error on other failures', async () => { - const promise = service.updateSecret('test-secret', updatePayload).toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - }); - - describe('deleteSecret', () => { - it('should delete a secret successfully', () => { - let result: DeleteSecretResponse | undefined; - - service.deleteSecret('test-secret').subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets/test-secret'); - expect(req.request.method).toBe('DELETE'); - req.flush(mockDeleteResponse); - - expect(result).toEqual(mockDeleteResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Secret deleted successfully'); - }); - - it('should emit secretsUpdated on successful deletion', () => { - let updateAction: string | undefined; - service.cast.subscribe((action) => { - updateAction = action; - }); - - service.deleteSecret('test-secret').subscribe(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush(mockDeleteResponse); - - expect(updateAction).toBe('deleted'); - }); - - it('should show error on delete failure', async () => { - const promise = service.deleteSecret('test-secret').toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should show default error message when error has no message', async () => { - const promise = service.deleteSecret('test-secret').toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret'); - req.flush({}, { status: 500, statusText: 'Internal Server Error' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to delete secret'); - }); - }); - - describe('getAuditLog', () => { - it('should fetch audit log with default pagination', () => { - let result: AuditLogResponse | undefined; - - service.getAuditLog('test-secret').subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); - expect(req.request.method).toBe('GET'); - req.flush(mockAuditLogResponse); - - expect(result).toEqual(mockAuditLogResponse); - }); - - it('should fetch audit log with custom pagination', () => { - let result: AuditLogResponse | undefined; - - service.getAuditLog('test-secret', 2, 25).subscribe((res) => { - result = res; - }); - - const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=2&limit=25'); - expect(req.request.method).toBe('GET'); - req.flush(mockAuditLogResponse); - - expect(result).toEqual(mockAuditLogResponse); - }); - - it('should show error on audit log fetch failure', async () => { - const promise = service.getAuditLog('test-secret').toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); - req.flush(fakeError, { status: 400, statusText: 'Bad Request' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should show default error message when error has no message', async () => { - const promise = service.getAuditLog('test-secret').toPromise(); - - const req = httpMock.expectOne('/secrets/test-secret/audit-log?page=1&limit=50'); - req.flush({}, { status: 500, statusText: 'Internal Server Error' }); - - await promise; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith('Failed to fetch audit log'); - }); - }); - - describe('cast observable', () => { - it('should initially emit empty string', () => { - let emittedValue: string | undefined; - - service.cast.subscribe((value) => { - emittedValue = value; - }); - - expect(emittedValue).toBe(''); - }); - - it('should emit actions when secrets are modified', () => { - const emittedValues: string[] = []; - - service.cast.subscribe((value) => { - emittedValues.push(value); - }); - - // Create a secret - service.createSecret({ slug: 'test', value: 'value' }).subscribe(); - const createReq = httpMock.expectOne('/secrets'); - createReq.flush(mockSecret); - - // Update a secret - service.updateSecret('test', { value: 'new-value' }).subscribe(); - const updateReq = httpMock.expectOne('/secrets/test'); - updateReq.flush(mockSecret); - - // Delete a secret - service.deleteSecret('test').subscribe(); - const deleteReq = httpMock.expectOne('/secrets/test'); - deleteReq.flush(mockDeleteResponse); - - expect(emittedValues).toEqual(['', 'created', 'updated', 'deleted']); - }); - }); -}); diff --git a/frontend/src/app/services/secrets.service.ts b/frontend/src/app/services/secrets.service.ts deleted file mode 100644 index 0e697440d..000000000 --- a/frontend/src/app/services/secrets.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { BehaviorSubject, EMPTY, Observable, throwError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { - AuditLogResponse, - CreateSecretPayload, - DeleteSecretResponse, - Secret, - SecretListResponse, - UpdateSecretPayload, -} from '../models/secret'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class SecretsService { - private secretsUpdated = new BehaviorSubject(''); - public cast = this.secretsUpdated.asObservable(); - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - ) {} - - fetchSecrets(page: number = 1, limit: number = 20, search?: string): Observable { - let params = new HttpParams().set('page', page.toString()).set('limit', limit.toString()); - - if (search) { - params = params.set('search', search); - } - - return this._http.get('/secrets', { params }).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch secrets'); - return EMPTY; - }), - ); - } - - createSecret(payload: CreateSecretPayload): Observable { - return this._http.post('/secrets', payload).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Secret created successfully'); - this.secretsUpdated.next('created'); - return res; - }), - catchError((err) => { - console.log(err); - if (err.status === 409) { - this._notifications.showErrorSnackbar('A secret with this slug already exists'); - } else { - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create secret'); - } - return EMPTY; - }), - ); - } - - updateSecret(slug: string, payload: UpdateSecretPayload, masterPassword?: string): Observable { - let headers = new HttpHeaders(); - if (masterPassword) { - headers = headers.set('masterpwd', masterPassword); - } - - return this._http.put(`/secrets/${slug}`, payload, { headers }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Secret updated successfully'); - this.secretsUpdated.next('updated'); - return res; - }), - catchError((err) => { - console.log(err); - if (err.status === 403) { - return throwError(() => err); - } - if (err.status === 410) { - this._notifications.showErrorSnackbar('Cannot update an expired secret'); - } else { - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update secret'); - } - return EMPTY; - }), - ); - } - - deleteSecret(slug: string): Observable { - return this._http.delete(`/secrets/${slug}`).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Secret deleted successfully'); - this.secretsUpdated.next('deleted'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete secret'); - return EMPTY; - }), - ); - } - - getAuditLog(slug: string, page: number = 1, limit: number = 50): Observable { - const params = new HttpParams().set('page', page.toString()).set('limit', limit.toString()); - - return this._http.get(`/secrets/${slug}/audit-log`, { params }).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch audit log'); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/selfhosted.service.ts b/frontend/src/app/services/selfhosted.service.ts deleted file mode 100644 index af4f52a2f..000000000 --- a/frontend/src/app/services/selfhosted.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { computed, Injectable, inject, signal } from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; - -export interface IsConfiguredResponse { - isConfigured: boolean; -} - -export interface CreateInitialUserRequest { - email: string; - password: string; -} - -export interface CreateInitialUserResponse { - success: boolean; -} - -@Injectable({ - providedIn: 'root', -}) -export class SelfhostedService { - private _notifications = inject(NotificationsService); - - private _isConfigured = signal(null); - private _isCheckingConfiguration = signal(false); - - public readonly isConfigured = this._isConfigured.asReadonly(); - public readonly isCheckingConfiguration = this._isCheckingConfiguration.asReadonly(); - public readonly isSelfHosted = computed(() => !(environment as any).saas); - - async checkConfiguration(): Promise { - this._isCheckingConfiguration.set(true); - try { - const response = await fetch('/api/selfhosted/is-configured'); - if (!response.ok) { - throw new Error(`HTTP error: ${response.status}`); - } - const data: IsConfiguredResponse = await response.json(); - this._isConfigured.set(data.isConfigured); - this._isCheckingConfiguration.set(false); - return data; - } catch (err) { - console.error('Failed to check configuration:', err); - this._isCheckingConfiguration.set(false); - // If the endpoint fails, assume configured to avoid blocking login - this._isConfigured.set(true); - return { isConfigured: true }; - } - } - - async createInitialUser(userData: CreateInitialUserRequest): Promise { - try { - const response = await fetch('/api/selfhosted/initial-user', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(userData), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw { error: errorData, message: `HTTP error: ${response.status}` }; - } - - const data: CreateInitialUserResponse = await response.json(); - this._notifications.showSuccessSnackbar('Admin account created successfully.'); - this._isConfigured.set(true); - return data; - } catch (err: any) { - console.error('Failed to create initial user:', err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - throw err; - } - } - - resetConfigurationState(): void { - this._isConfigured.set(null); - } -} diff --git a/frontend/src/app/services/table-row.service.spec.ts b/frontend/src/app/services/table-row.service.spec.ts deleted file mode 100644 index de63002c4..000000000 --- a/frontend/src/app/services/table-row.service.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; -import { TableRowService } from './table-row.service'; - -describe('TableRowService', () => { - let service: TableRowService; - let httpMock: HttpTestingController; - - let fakeNotifications; - - const tableRowValues = { - Id: 11, - FirstName: 'Yuriy', - }; - - const tableRowNetwork = { - row: tableRowValues, - structure: [ - { - column_name: 'FirstName', - column_default: null, - data_type: 'varchar', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }, - { - column_name: 'Id', - column_default: null, - data_type: 'int', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 11, - }, - ], - foreignKeys: [], - primaryColumns: [ - { - data_type: 'int', - column_name: 'Id', - }, - ], - readonly_fields: [], - table_widgets: [], - }; - - const fakeError = { - message: 'Table row error', - statusCode: 400, - type: 'no_master_key', - originalMessage: 'Table row error details', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - ], - }); - - service = TestBed.inject(TableRowService); - httpMock = TestBed.inject(HttpTestingController); - }); - - afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should call fetchTableRow', () => { - let isSubscribeCalled = false; - - service.fetchTableRow('12345678', 'users_table', { id: 1 }).subscribe((res) => { - expect(res).toEqual(tableRowNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/row/12345678?id=1&tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(tableRowNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call addTableRow and show Success snackbar', () => { - let isSubscribeCalled = false; - - service.addTableRow('12345678', 'users_table', tableRowValues).subscribe((res) => { - expect(res).toEqual(tableRowValues); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'The row has been added successfully to "users_table" table.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/row/12345678?tableName=users_table`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(tableRowValues); - req.flush(tableRowValues); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall addTableRow and show Error alert', async () => { - const addTableRow = service.addTableRow('12345678', 'users_table', tableRowValues).toPromise(); - - const req = httpMock.expectOne(`/table/row/12345678?tableName=users_table`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await addTableRow; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call updateTableRow and show Success snackbar', () => { - let isSubscribeCalled = false; - - service.updateTableRow('12345678', 'users_table', { id: 1 }, tableRowValues).subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'The row has been updated successfully in "users_table" table.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/row/12345678?id=1&tableName=users_table`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual(tableRowValues); - req.flush(tableRowValues); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall updateTableRow and show Error alert', async () => { - const addTableRow = service.updateTableRow('12345678', 'users_table', { id: 1 }, tableRowValues).toPromise(); - - const req = httpMock.expectOne(`/table/row/12345678?id=1&tableName=users_table`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await addTableRow; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call deleteTableRow and show Success snackbar', () => { - let isSubscribeCalled = false; - - service.deleteTableRow('12345678', 'users_table', { id: 1 }).subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'Row has been deleted successfully from "users_table" table.', - ); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/row/12345678?id=1&tableName=users_table`); - expect(req.request.method).toBe('DELETE'); - req.flush({ deleted: true }); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall deleteTableRow and show Error snackbar', async () => { - const deleteTableRow = service.deleteTableRow('12345678', 'users_table', { id: 1 }).toPromise(); - - const req = httpMock.expectOne(`/table/row/12345678?id=1&tableName=users_table`); - expect(req.request.method).toBe('DELETE'); - req.flush(fakeError, { status: 400, statusText: '' }); - await deleteTableRow; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); -}); diff --git a/frontend/src/app/services/table-row.service.ts b/frontend/src/app/services/table-row.service.ts deleted file mode 100644 index 7b5611661..000000000 --- a/frontend/src/app/services/table-row.service.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class TableRowService { - private row; - public cast; - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - ) { - this.row = new Subject(); - this.cast = this.row.asObservable(); - } - - fetchTableRow(connectionID: string, tableName: string, params) { - return this._http - .get(`/table/row/${connectionID}`, { - params: { - ...params, - tableName, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - addTableRow(connectionID: string, tableName: string, row) { - return this._http - .post(`/table/row/${connectionID}`, row, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`The row has been added successfully to "${tableName}" table.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - updateTableRow(connectionID: string, tableName: string, params, tableRow) { - return this._http - .put(`/table/row/${connectionID}`, tableRow, { - params: { - ...params, - tableName, - }, - }) - .pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`The row has been updated successfully in "${tableName}" table.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - deleteTableRow(connectionID: string, tableName: string, params: object) { - return this._http - .delete(`/table/row/${connectionID}`, { - params: { - ...params, - tableName, - }, - }) - .pipe( - map(() => { - this.row.next('delete row'); - this._notifications.showSuccessSnackbar(`Row has been deleted successfully from "${tableName}" table.`); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/table-schema.service.ts b/frontend/src/app/services/table-schema.service.ts deleted file mode 100644 index 35705f23d..000000000 --- a/frontend/src/app/services/table-schema.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { environment } from '../../environments/environment'; -import { ApiService } from './api.service'; - -export interface ConnectionDiagramResponse { - connectionId: string; - databaseType: string; - diagram: string; - description: string; - generatedAt: string; -} - -export interface ConnectionDiagramPreviewStatementResult { - sql: string; - status: 'applied' | 'skipped' | 'error'; - message?: string; -} - -export interface ConnectionDiagramPreviewDiff { - addedTables: string[]; - droppedTables: string[]; - addedColumns: Record; - droppedColumns: Record; - addedForeignKeys: Record; - statementResults: ConnectionDiagramPreviewStatementResult[]; -} - -export interface ConnectionDiagramPreviewResponse { - connectionId: string; - databaseType: string; - diagram: string; - description: string; - diff: ConnectionDiagramPreviewDiff; - generatedAt: string; -} - -export interface SchemaChangeResponse { - id: string; - connectionId: string; - batchId: string | null; - orderInBatch: number; - forwardSql: string; - rollbackSql: string | null; - userModifiedSql: string | null; - status: string; - changeType: string; - targetTableName: string; - databaseType: string; - executionError: string | null; - isReversible: boolean; - userPrompt: string; - aiSummary: string | null; - aiReasoning: string | null; - createdAt: string; - appliedAt: string | null; - rolledBackAt: string | null; - aiAutoFixApplied?: boolean; - aiAutoFixOriginalForwardSql?: string | null; - aiAutoFixOriginalRollbackSql?: string | null; - aiAutoFixOriginalError?: string | null; -} - -export interface SchemaChangeBatchResponse { - batchId: string | null; - changes: SchemaChangeResponse[]; - threadId?: string | null; - assistantMessage?: string | null; -} - -@Injectable({ - providedIn: 'root', -}) -export class TableSchemaService { - private _api = inject(ApiService); - - async generateSchemaChange( - connectionId: string, - userPrompt: string, - threadId?: string, - ): Promise { - return this._fetchOrThrow(`/table-schema/${connectionId}/generate`, { - userPrompt, - threadId, - }); - } - - async approveBatch(batchId: string, confirmedDestructive?: boolean): Promise { - return this._fetchOrThrow(`/table-schema/batch/${batchId}/approve`, { - confirmedDestructive, - }); - } - - async rejectBatch(batchId: string): Promise { - return this._api.post(`/table-schema/batch/${batchId}/reject`); - } - - async fetchDiagram(connectionId: string): Promise { - return this._api.get(`/connection/diagram/${connectionId}`); - } - - async previewDiagram(connectionId: string, sqlCommands: string[]): Promise { - return this._fetchOrThrow(`/connection/diagram/${connectionId}/preview`, { - sqlCommands, - }); - } - - private async _fetchOrThrow(url: string, body: unknown): Promise { - const fullUrl = `${environment.apiRoot || '/api'}${url}`; - const response = await fetch(fullUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - const message = errorBody?.message || `Request failed (${response.status})`; - throw new Error(message); - } - - return response.json(); - } -} diff --git a/frontend/src/app/services/table-state.service.spec.ts b/frontend/src/app/services/table-state.service.spec.ts deleted file mode 100644 index 6ddb67e15..000000000 --- a/frontend/src/app/services/table-state.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { TableStateService } from './table-state.service'; - -describe('TableStateService', () => { - let service: TableStateService; - - beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.inject(TableStateService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/table-state.service.ts b/frontend/src/app/services/table-state.service.ts deleted file mode 100644 index bd318f3d7..000000000 --- a/frontend/src/app/services/table-state.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { TableRow } from '../models/table'; - -@Injectable({ - providedIn: 'root', -}) -export class TableStateService { - private selectedRowSubject = new BehaviorSubject(null); - cast = this.selectedRowSubject.asObservable(); - - private aiPanelSubject = new BehaviorSubject(false); - aiPanelCast = this.aiPanelSubject.asObservable(); - - private aiPanelExpandedSubject = new BehaviorSubject(false); - aiPanelExpandedCast = this.aiPanelExpandedSubject.asObservable(); - - // private backUrlFilters: any; - // private backUrlParams: any; - - private getSessionStorageItem(key: string) { - const item = sessionStorage.getItem(key); - if (item) { - try { - return JSON.parse(item); - } catch (e) { - console.error(`Error parsing JSON from sessionStorage for key "${key}":`, e); - return null; - } - } - return null; - } - - private setSessionStorageItem(key: string, value: any) { - sessionStorage.setItem(key, JSON.stringify(value)); - } - - setBackUrlParams(pageIndex: number, pageSize: number, sortField: string, sortDirection: string) { - console.log('setBackUrlParams', pageIndex, pageSize, sortField, sortDirection); - const params = { - page_index: pageIndex, - page_size: pageSize, - ...(sortField !== undefined && { sort_active: sortField }), - ...(sortDirection !== undefined && { sort_direction: sortDirection.toUpperCase() }), - }; - this.setSessionStorageItem('backUrlParams', params); - } - - getBackUrlParams() { - return this.getSessionStorageItem('backUrlParams'); - } - - setBackUrlFilters(filters: any) { - this.setSessionStorageItem('backUrlFilters', filters); - } - - getBackUrlFilters() { - return this.getSessionStorageItem('backUrlFilters'); - } - - selectRow(row: TableRow) { - if ( - this.selectedRowSubject.value && - JSON.stringify(this.selectedRowSubject.value.primaryKeys) === JSON.stringify(row.primaryKeys) - ) { - this.selectedRowSubject.next(null); - } else { - this.selectedRowSubject.next(row); - this.closeAIpanel(); - } - } - - handleViewAIpanel() { - this.clearSelection(); - const isOpening = !this.aiPanelSubject.value; - this.aiPanelSubject.next(isOpening); - if (!isOpening) { - this.aiPanelExpandedSubject.next(false); - } - } - - clearSelection() { - this.selectedRowSubject.next(null); - } - - closeAIpanel() { - this.aiPanelSubject.next(false); - this.aiPanelExpandedSubject.next(false); - } - - toggleAIPanelExpanded() { - const newValue = !this.aiPanelExpandedSubject.value; - this.aiPanelExpandedSubject.next(newValue); - } -} diff --git a/frontend/src/app/services/tables.service.spec.ts b/frontend/src/app/services/tables.service.spec.ts deleted file mode 100644 index e5dfcdf53..000000000 --- a/frontend/src/app/services/tables.service.spec.ts +++ /dev/null @@ -1,630 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { Angulartics2Module } from 'angulartics2'; -import { AlertActionType, AlertType } from '../models/alert'; -import { TableOrdering } from '../models/table'; -import { NotificationsService } from './notifications.service'; -import { TablesService } from './tables.service'; - -describe('TablesService', () => { - let service: TablesService; - let httpMock: HttpTestingController; - - let fakeNotifications; - - const structureNetwork = [ - { - column_name: 'id', - column_default: "nextval('customers_id_seq'::regclass)", - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: true, - allow_null: false, - character_maximum_length: null, - }, - { - column_name: 'firstname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'lastname', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: 30, - }, - { - column_name: 'email', - column_default: null, - data_type: 'character varying', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: false, - character_maximum_length: 30, - }, - { - column_name: 'age', - column_default: null, - data_type: 'integer', - isExcluded: false, - isSearched: false, - auto_increment: false, - allow_null: true, - character_maximum_length: null, - }, - ]; - - const usersTableNetwork = { - rows: [ - { - id: 33, - firstname: 'Alex', - lastname: 'Lichter', - email: 'new-user-5@email.com', - age: 24, - }, - { - id: 34, - firstname: 'Alex', - lastname: 'Lichter', - email: 'new-user-5@email.com', - age: 24, - }, - { - id: 35, - firstname: 'Alex', - lastname: 'Smith', - email: 'some-new@email.com', - age: 24, - }, - ], - primaryColumns: [ - { - column_name: 'id', - data_type: 'integer', - }, - ], - pagination: { - total: 30, - lastPage: 1, - perPage: 30, - currentPage: 1, - }, - sortable_by: [], - ordering: 'ASC', - structure: structureNetwork, - foreignKeys: [], - }; - - const tableSettingsNetwork = { - id: 'dbf4d648-32f8-4202-9c18-300e1a4dc959', - table_name: 'contacts_with_uuid', - display_name: '', - search_fields: [], - excluded_fields: [], - list_fields: [], - identification_fields: [], - list_per_page: null, - ordering: 'ASC', - ordering_field: '', - identity_column: '', - readonly_fields: [], - sortable_by: [], - autocomplete_columns: ['first_name', 'last_name', 'email'], - columns_view: ['first_name', 'last_name', 'email'], - connection_id: 'e4b99271-badd-4112-9967-b99dd8024dda', - }; - - const tableSettingsApp = { - autocomplete_columns: ['first_name', 'last_name', 'email'], - columns_view: ['first_name', 'last_name', 'email'], - connection_id: 'e4b99271-badd-4112-9967-b99dd8024dda', - display_name: '', - icon: '', - excluded_fields: [], - id: 'dbf4d648-32f8-4202-9c18-300e1a4dc959', - identification_fields: [], - identity_column: '', - list_fields: [], - list_per_page: null, - ordering: TableOrdering.Descending, - ordering_field: '', - readonly_fields: [], - search_fields: [], - sortable_by: [], - table_name: 'contacts_with_uuid', - sensitive_fields: [], - allow_csv_export: true, - allow_csv_import: true, - can_delete: true, - }; - - const tableWidgetsNetwork = [ - { - id: 'a57e0c7f-a348-4aae-9ec4-fdbec0c0d0b6', - field_name: 'email', - widget_type: 'Textarea', - widget_params: {}, - name: 'user email', - description: '', - }, - ]; - - const tableWidgetsApp = [ - { - description: '', - field_name: 'email', - name: 'user email', - widget_params: '', - widget_type: 'Textarea', - }, - ]; - - const fakeError = { - message: 'Connection error', - statusCode: 400, - type: 'no_master_key', - originalMessage: 'Connection error details', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, Angulartics2Module.forRoot()], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - provideRouter([]), - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - ], - }); - - service = TestBed.inject(TablesService); - httpMock = TestBed.inject(HttpTestingController); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should call setTableName', () => { - service.setTableName('users_table'); - expect(service.tableName).toEqual('users_table'); - }); - - it('should get currentTableName', () => { - service.tableName = 'users_table'; - expect(service.currentTableName).toEqual('users_table'); - }); - - it('should call fetchTables', () => { - let isSubscribeCalled = false; - const tablesNetwork = [ - { - '0': { - table: 'users', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - }, - { - '1': { - table: 'addresses', - permissions: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - }, - ]; - - service.fetchTables('12345678').subscribe((res) => { - expect(res).toEqual(tablesNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/tables/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(tablesNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchTable with minimal params', () => { - let isSubscribeCalled = false; - - service - .fetchTable({ - connectionID: '12345678', - tableName: 'users_table', - requstedPage: 1, - chunkSize: 30, - }) - .subscribe((res) => { - expect(res).toEqual(usersTableNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/rows/find/12345678?tableName=users_table&perPage=30&page=1`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ filters: undefined }); - req.flush(usersTableNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchTable for foreigh keys', () => { - let isSubscribeCalled = false; - - service - .fetchTable({ - connectionID: '12345678', - tableName: 'users_table', - requstedPage: 1, - chunkSize: 30, - foreignKeyRowName: 'position_id', - foreignKeyRowValue: '9876', - referencedColumn: 'id', - }) - .subscribe((res) => { - expect(res).toEqual(usersTableNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne( - `/table/rows/find/12345678?tableName=users_table&perPage=30&page=1&f_position_id__eq=9876&referencedColumn=id`, - ); - expect(req.request.method).toBe('POST'); - req.flush(usersTableNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchTable with filters', () => { - let isSubscribeCalled = false; - - service - .fetchTable({ - connectionID: '12345678', - tableName: 'users_table', - requstedPage: 1, - chunkSize: 30, - filters: { - city: { - eq: 'NewYork', - }, - age: { - eq: '42', - }, - }, - }) - .subscribe((res) => { - expect(res).toEqual(usersTableNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne('/table/rows/find/12345678?tableName=users_table&perPage=30&page=1'); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - filters: { - city: { - eq: 'NewYork', - }, - age: { - eq: '42', - }, - }, - }); - req.flush(usersTableNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call fetchTable with sortOrder DESC and sort_by City column', () => { - let isSubscribeCalled = false; - - service - .fetchTable({ - connectionID: '12345678', - tableName: 'users_table', - requstedPage: 1, - chunkSize: 30, - sortOrder: 'DESC', - sortColumn: 'city', - }) - .subscribe((res) => { - expect(res).toEqual(usersTableNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne( - `/table/rows/find/12345678?tableName=users_table&perPage=30&page=1&sort_by=city&sort_order=DESC`, - ); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ filters: undefined }); - req.flush(usersTableNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall fetchTable and show Error alert', async () => { - const fetchTableRow = service - .fetchTable({ - connectionID: '12345678', - tableName: 'users_table', - requstedPage: 1, - chunkSize: 30, - }) - .toPromise(); - - const req = httpMock.expectOne(`/table/rows/find/12345678?tableName=users_table&perPage=30&page=1`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableRow; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call fetchTableStructure', () => { - let isSubscribeCalled = false; - const tableStructureNetwork = { - structure: structureNetwork, - foreignKeys: [], - readonly_fields: [], - table_widgets: [], - }; - - service.fetchTableStructure('12345678', 'users_table').subscribe((res) => { - expect(res).toEqual(tableStructureNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/table/structure/12345678?tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(tableStructureNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall fetchTableStructure and show Error snackbar', async () => { - const fetchTableStructure = service.fetchTableStructure('12345678', 'users_table').toPromise(); - - const req = httpMock.expectOne(`/table/structure/12345678?tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableStructure; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call fetchTableSettings', () => { - let isSubscribeCalled = false; - - service.fetchTableSettings('12345678', 'users_table').subscribe((res) => { - expect(res).toEqual(tableSettingsNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(tableSettingsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall fetchTableSettings and show Error snackbar', async () => { - const fetchTableSettings = service.fetchTableSettings('12345678', 'users_table').toPromise(); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableSettings; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call updateTableSettings for existing settings', () => { - let isSubscribeCalled = false; - - service.updateTableSettings(true, '12345678', 'users_table', tableSettingsApp).subscribe((_res) => { - // expect(res).toEqual(tableSettingsNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual(tableSettingsApp); - req.flush(tableSettingsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should call updateTableSettings and create settings', () => { - let isSubscribeCalled = false; - - service.updateTableSettings(false, '12345678', 'users_table', tableSettingsApp).subscribe((_res) => { - // expect(res).toEqual(tableSettingsNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual(tableSettingsApp); - req.flush(tableSettingsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall updateTableSettings and show Error alert', async () => { - const fetchTableSettings = service - .updateTableSettings(true, '12345678', 'users_table', tableSettingsApp) - .toPromise(); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableSettings; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call deleteTableSettings', () => { - let isSubscribeCalled = false; - - service.deleteTableSettings('12345678', 'users_table').subscribe((_res) => { - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('DELETE'); - req.flush(tableSettingsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall deleteTableSettings and show Error snackbar', async () => { - const fetchTableSettings = service.deleteTableSettings('12345678', 'users_table').toPromise(); - - const req = httpMock.expectOne(`/settings?connectionId=12345678&tableName=users_table`); - expect(req.request.method).toBe('DELETE'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableSettings; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call fetchTableWidgets', () => { - let isSubscribeCalled = false; - - service.fetchTableWidgets('12345678', 'users_table').subscribe((_res) => { - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/widgets/12345678?tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(tableSettingsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall fetchTableWidgets and show Error alert', async () => { - const fetchTableSettings = service.fetchTableWidgets('12345678', 'users_table').toPromise(); - - const req = httpMock.expectOne(`/widgets/12345678?tableName=users_table`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableSettings; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call updateTableWidgets', () => { - let isSubscribeCalled = false; - - service.updateTableWidgets('12345678', 'users_table', tableWidgetsApp).subscribe((res) => { - expect(res).toEqual(tableWidgetsNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne('/widget/12345678?tableName=users_table'); - expect(req.request.method).toBe('POST'); - req.flush(tableWidgetsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall updateTableWidgets and show Error bannner', async () => { - const fetchTableSettings = service.updateTableWidgets('12345678', 'users_table', tableWidgetsApp).toPromise(); - - const req = httpMock.expectOne('/widget/12345678?tableName=users_table'); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchTableSettings; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); -}); diff --git a/frontend/src/app/services/tables.service.ts b/frontend/src/app/services/tables.service.ts deleted file mode 100644 index 5d49647dc..000000000 --- a/frontend/src/app/services/tables.service.ts +++ /dev/null @@ -1,727 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { BehaviorSubject, EMPTY, throwError } from 'rxjs'; -import { catchError, filter, map } from 'rxjs/operators'; -import { AlertActionType, AlertType } from '../models/alert'; -import { PersonalTableViewSettings, Rule, TableSettings, Widget } from '../models/table'; -import { NotificationsService } from './notifications.service'; - -export enum SortOrdering { - Ascending = 'ASC', - Descending = 'DESC', -} - -interface TableParams { - connectionID: string; - tableName: string; - requstedPage?: number; - chunkSize?: number; - sortColumn?: string; - sortOrder?: 'ASC' | 'DESC'; - foreignKeyRowName?: string; - foreignKeyRowValue?: string; - referencedColumn?: string; - filters?: object; - comparators?: object; - search?: string; - uncached?: boolean; -} - -@Injectable({ - providedIn: 'root', -}) -export class TablesService { - public tableName: string | null = null; - - private tables = new BehaviorSubject(''); - public cast = this.tables.asObservable(); - - constructor( - private _http: HttpClient, - private router: Router, - private _notifications: NotificationsService, - private angulartics2: Angulartics2, - ) { - this.router = router; - - this.router.events - .pipe( - filter((event): boolean => { - return event instanceof NavigationEnd; - }), - ) - .subscribe((_event: NavigationEnd): void => { - this.setTableName(this.router.routerState.snapshot.root.firstChild.paramMap.get('table-name')); - }); - } - - setTableName(tableName: string) { - this.tableName = tableName; - } - - get currentTableName() { - return this.tableName; - } - - fetchTables(connectionID: string, hidden?: boolean) { - return this._http - .get(`/connection/tables/${connectionID}`, { - params: { - ...(hidden ? { hidden } : {}), - }, - }) - .pipe( - map((res) => { - return res; - }), - ); - } - - fetchTablesFolders(connectionID: string, hidden?: boolean) { - console.log('fetchTablesFolders service'); - return this._http - .get(`/table-categories/v2/${connectionID}`, { - params: { - ...(hidden ? { hidden } : {}), - }, - }) - .pipe( - map((res) => { - return res; - }), - ); - } - - updateTablesFolders(connectionID: string, tablesFolders: any) { - return this._http.put(`/table-categories/${connectionID}`, tablesFolders).pipe( - map((res) => { - // this._notifications.showSuccessSnackbar('Connection settings has been updated successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - const errorMessage = err.error?.message || 'Unknown error'; - this._notifications.showErrorSnackbar(`${errorMessage}.`); - return EMPTY; - }), - ); - } - - fetchTable({ - connectionID, - tableName, - requstedPage, - chunkSize, - sortColumn, - sortOrder, - foreignKeyRowName, - foreignKeyRowValue, - referencedColumn, - filters, - search, - uncached, - }: TableParams) { - let foreignKeyRowParamName = - foreignKeyRowName === 'autocomplete' ? foreignKeyRowName : `f_${foreignKeyRowName}__eq`; - - if (tableName) { - return this._http - .post( - `/table/rows/find/${connectionID}`, - { filters }, - { - params: { - tableName, - perPage: chunkSize.toString(), - page: requstedPage.toString(), - ...(search ? { search } : {}), - ...(foreignKeyRowValue ? { [foreignKeyRowParamName]: foreignKeyRowValue } : {}), - ...(referencedColumn ? { referencedColumn } : {}), - ...(sortColumn ? { sort_by: sortColumn } : {}), - ...(sortOrder ? { sort_order: sortOrder } : {}), - ...(uncached ? { _uncached: 'true' } : {}), - }, - }, - ) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - // this._notifications.showErrorSnackbar(err.error?.message || err.message); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - } - - fetchTableStructure(connectionID: string, tableName: string) { - return this._http - .get(`/table/structure/${connectionID}`, { - params: { - tableName, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - fetchTableSettings(connectionID: string, tableName: string) { - return this._http - .get('/settings', { - params: { - connectionId: connectionID, - tableName, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - exportTableCSV({ connectionID, tableName, chunkSize, sortColumn, sortOrder, filters, search }) { - return this._http - .post( - `/table/csv/export/${connectionID}`, - { filters }, - { - params: { - perPage: chunkSize.toString(), - page: '1', - tableName, - ...(search ? { search } : {}), - ...(sortColumn ? { sort_by: sortColumn } : {}), - ...(sortOrder ? { sort_order: sortOrder } : {}), - }, - responseType: 'text' as 'json', - }, - ) - .pipe( - map((res) => { - return res; - }), - catchError((err) => { - console.log(err); - const errorObj = JSON.parse(err.error); - this._notifications.showErrorSnackbar(errorObj.message); - this.angulartics2.eventTrack.next({ - action: 'Dashboard: db export failed', - }); - posthog.capture('Dashboard: db export failed'); - return EMPTY; - }), - ); - } - - importTableCSV(connectionID: string, tableName: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - return this._http - .post(`/table/csv/import/${connectionID}`, formData, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this.tables.next('import'); - this._notifications.showSuccessSnackbar('CSV file has been imported successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - this.angulartics2.eventTrack.next({ - action: 'Dashboard: db import failed', - }); - posthog.capture('Dashboard: db import failed'); - return EMPTY; - }), - ); - } - - updateTableSettings(isSettingsExist: boolean, connectionID: string, tableName: string, settings: TableSettings) { - let method: string; - if (isSettingsExist) { - method = 'put'; - } else method = 'post'; - - return this._http[method]('/settings', settings, { - params: { - connectionId: connectionID, - tableName, - }, - }).pipe( - map(() => { - this.tables.next('settings'); - this._notifications.showSuccessSnackbar('Table settings has been updated.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - deleteTableSettings(connectionID: string, tableName: string) { - return this._http - .delete('/settings', { - params: { - connectionId: connectionID, - tableName, - }, - }) - .pipe( - map(() => { - this.tables.next('settings'); - this._notifications.showSuccessSnackbar('Table settings has been reset.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - fetchTableWidgets(connectionID: string, tableName: string) { - return this._http - .get(`/widgets/${connectionID}`, { - params: { - tableName, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - updateTableWidgets(connectionID: string, tableName: string, widgets: Widget[]) { - return this._http - .post( - `/widget/${connectionID}`, - { widgets }, - { - params: { - tableName, - }, - }, - ) - .pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Table widgets has been updated.'); - return res; - }), - catchError((err) => { - console.log(err); - // this._notifications.showErrorSnackbar(err.error?.message || err.message); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - bulkDelete(connectionID: string, tableName: string, primaryKeys) { - return this._http - .put(`/table/rows/delete/${connectionID}`, primaryKeys, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this.tables.next('delete rows'); - this._notifications.showSuccessSnackbar('Rows have been deleted successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - // this._notifications.showErrorSnackbar(err.error?.message || err.message); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - fetchRules(connectionID: string, tableName: string) { - return this._http - .get(`/action/rules/${connectionID}`, { - params: { - tableName, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - saveRule(connectionID: string, _tableName: string, rule: Rule) { - return this._http.post(`/action/rule/${connectionID}`, rule).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`${res.title} action has been created.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - updateRule(connectionID: string, _tableName: string, rule: Rule) { - return this._http.put(`/action/rule/${rule.id}/${connectionID}`, rule).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`${res.title} action has been updated.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - deleteRule(connectionID: string, _tableName: string, ruleId: string) { - return this._http.delete(`/action/rule/${ruleId}/${connectionID}`).pipe( - map((res) => { - this.tables.next('delete-rule'); - this._notifications.showSuccessSnackbar(`${res.title} action has been deleted.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - activateActions( - connectionID: string, - _tableName: string, - actionId: string, - actionTitle: string, - primaryKeys: object[], - _confirmed?: boolean, - ) { - return this._http.post(`/event/actions/activate/${actionId}/${connectionID}`, primaryKeys).pipe( - map((res) => { - this.tables.next('activate actions'); - this._notifications.showSuccessSnackbar(`${actionTitle} is done for ${primaryKeys.length} rows.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - createAIthread(connectionID, tableName, message) { - return this._http - .post( - `/ai/v4/request/${connectionID}`, - { user_message: message }, - { - responseType: 'text' as 'json', - observe: 'response', - params: { - tableName, - }, - }, - ) - .pipe( - map((res) => { - const threadId = res.headers.get('X-OpenAI-Thread-ID'); - this.angulartics2.eventTrack.next({ - action: 'AI: thread created', - }); - posthog.capture('AI: thread created'); - const responseMessage = res.body as string; - return { threadId, responseMessage }; - }), - catchError((err) => { - console.log(err); - return throwError(() => new Error(err.error?.message || err.message)); - }), - ); - } - - requestAImessage(connectionID: string, tableName: string, threadId: string, message: string) { - console.log('threadId', threadId); - return this._http - .post( - `/ai/v4/request/${connectionID}`, - { user_message: message }, - { - responseType: 'text' as 'json', - observe: 'response', - params: { - tableName, - threadId, - }, - }, - ) - .pipe(map((res) => res.body as string)); - } - - updatePersonalTableViewSettings(connectionID: string, tableName: string, settings: PersonalTableViewSettings) { - return this._http - .put(`/settings/personal/${connectionID}`, settings, { - params: { - tableName, - }, - }) - .pipe( - map(() => { - this.tables.next('settings'); - // this._notifications.showSuccessSnackbar('Table settings has been updated.') - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error.message, details: err.error.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - getSavedFilters(connectionID: string, tableName: string) { - return this._http - .get(`/table-filters/${connectionID}/all`, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - return res; - }), - catchError((err) => { - console.log(err); - return throwError(() => new Error(err.error.message)); - }), - ); - } - - createSavedFilter(connectionID: string, tableName: string, filters: object) { - return this._http - .post(`/table-filters/${connectionID}`, filters, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this.tables.next('filters set saved'); - this._notifications.showSuccessSnackbar('Saved filters have been updated.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message); - return EMPTY; - }), - ); - } - - updateSavedFilter(connectionID: string, tableName: string, filtersId: string, filters: object) { - return this._http - .put(`/table-filters/${connectionID}/${filtersId}`, filters, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this.tables.next('filters set updated'); - this._notifications.showSuccessSnackbar('Saved filter has been updated.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message); - return EMPTY; - }), - ); - } - - deleteSavedFilter(connectionID: string, tableName: string, filterId: string) { - return this._http - .delete(`/table-filters/${connectionID}/${filterId}`, { - params: { - tableName, - }, - }) - .pipe( - map((res) => { - this.tables.next('delete saved filters'); - this._notifications.showSuccessSnackbar('Saved filter has been deleted.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/token.interceptor.spec.ts b/frontend/src/app/services/token.interceptor.spec.ts deleted file mode 100644 index 00e89fcc1..000000000 --- a/frontend/src/app/services/token.interceptor.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { ConfigurationService } from './configuration.service'; -import { TokenInterceptor } from './token.interceptor'; - -describe('TokenInterceptor', () => { - beforeEach(() => - TestBed.configureTestingModule({ - imports: [MatSnackBarModule, MatDialogModule], - providers: [ - provideHttpClient(), - provideRouter([]), - TokenInterceptor, - { - provide: ConfigurationService, - useValue: { CONFIG_URL: 'https://app.rocketadmin.com/api' }, - }, - ], - }), - ); - - it('should be created', () => { - const interceptor: TokenInterceptor = TestBed.inject(TokenInterceptor); - expect(interceptor).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/token.interceptor.ts b/frontend/src/app/services/token.interceptor.ts deleted file mode 100644 index 4ea8c77a9..000000000 --- a/frontend/src/app/services/token.interceptor.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { CookieService } from 'ngx-cookie-service'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { environment } from '../../environments/environment'; -import { ConfigurationService } from './configuration.service'; -import { ConnectionsService } from './connections.service'; - -@Injectable() -export class TokenInterceptor implements HttpInterceptor { - private config; - - constructor( - private cookieService: CookieService, - private _connections: ConnectionsService, - private _configuration: ConfigurationService, - ) {} - - normalizeURL(url: string, baseURL: string, saasURL: string): string { - if (url.startsWith('/assets')) { - return undefined; - } - if (url.startsWith('/saas')) { - return `${saasURL}${url}`; - } - if (url.startsWith('http://')) { - return url; - } - if (url.startsWith('https://')) { - return url; - } - return `${baseURL}${url}`; - } - - intercept(request: HttpRequest, next: HttpHandler): Observable> { - this.config = this._configuration.getConfig(); - const connectionID = this._connections.currentConnectionID; - const url = new URL(request.url, location.origin); - const isApiRequest = url.origin === location.origin || url.origin == 'https://app.rocketadmin.com'; - - request = request.clone({ - url: this.normalizeURL(request.url, environment.apiRoot || this.config.baseURL, environment.saasURL), - }); - - if (isApiRequest) { - const autoadmin_gclid_cookie = this.cookieService.get('autoadmin_gclid'); - request = request.clone({ - setHeaders: { - GCLID: autoadmin_gclid_cookie, - }, - withCredentials: true, - }); - - if (connectionID && !request.headers.has('masterpwd')) { - const masterKey = localStorage.getItem(`${connectionID}__masterKey`) || ''; - request = request.clone({ - setHeaders: { - masterpwd: masterKey, - }, - }); - } - } - - return next.handle(request).pipe( - catchError((error: HttpErrorResponse) => { - if (error.status === 401) { - localStorage.removeItem('token_expiration'); - location.href = '/login'; - } - return throwError(error); - }), - ); - } -} diff --git a/frontend/src/app/services/ui-settings.service.spec.ts b/frontend/src/app/services/ui-settings.service.spec.ts deleted file mode 100644 index 8d725a2e3..000000000 --- a/frontend/src/app/services/ui-settings.service.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { UiSettingsService } from './ui-settings.service'; - -describe('UiSettingsService', () => { - let service: UiSettingsService; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [provideHttpClient()], - }); - service = TestBed.inject(UiSettingsService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/ui-settings.service.ts b/frontend/src/app/services/ui-settings.service.ts deleted file mode 100644 index 3e386f9d4..000000000 --- a/frontend/src/app/services/ui-settings.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { EMPTY, Observable } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { ConnectionSettingsUI, GlobalSettingsUI, UiSettings } from '../models/ui-settings'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class UiSettingsService { - public settings: UiSettings = { - globalSettings: {} as GlobalSettingsUI, - connections: {} as { [connectionId: string]: ConnectionSettingsUI }, - }; - - private uiSettings = null; - private _isDarkMode: boolean; - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - ) { - this._isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches; - } - - get isDarkMode(): boolean { - return this._isDarkMode; - } - - updateGlobalSetting(key: string, value: any) { - this.settings.globalSettings[key] = value; - this.syncUiSettings().subscribe(); - } - - updateConnectionSetting(connectionId: string, key: string, value: any) { - if (!this.settings.connections[connectionId]) { - this.settings.connections[connectionId] = { shownTableTitles: false, tables: {} }; - } - this.settings.connections[connectionId][key] = value; - this.syncUiSettings().subscribe(); - } - - updateTableSetting(connectionId: string, tableName: string, key: string, value: any) { - console.log('updateTableSetting'); - if (!this.settings.connections[connectionId]) { - this.settings.connections[connectionId] = { shownTableTitles: false, tables: {} }; - } - // if (!this.settings.connections[connectionId].tables[tableName]) { - // this.settings.connections[connectionId].tables[tableName] = { shownColumns: [] }; - // } - this.settings.connections[connectionId].tables[tableName][key] = value; - this.syncUiSettings().subscribe(); - } - - getUiSettings(): Observable { - if (!this.uiSettings) { - return this._http.get('/user/settings').pipe( - map((res) => { - const settings = res.userSettings ? JSON.parse(res.userSettings) : null; - console.log('getUiSettings settings'); - console.log(settings); - if (settings) this.settings = settings; - this.uiSettings = settings; - return settings; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } else { - return new Observable((s) => s.next(this.uiSettings)); - } - } - - syncUiSettings() { - return this._http.post('/user/settings', { userSettings: JSON.stringify(this.settings) }).pipe( - map((res) => { - const settings = res.userSettings ? JSON.parse(res.userSettings) : null; - this.uiSettings = settings; - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } -} - -// { -// "globalSettings": { -// "connectionsListCollapsed": false -// }, -// "connections": { -// "[connection-id]": { -// "shownTableTitles": false, -// "tables": { -// "[table-name]": { -// "shownColumns": ["column1", "column2", "column3"] -// } -// } -// } -// } -// } diff --git a/frontend/src/app/services/url-params.interceptor.spec.ts b/frontend/src/app/services/url-params.interceptor.spec.ts deleted file mode 100644 index e8730722d..000000000 --- a/frontend/src/app/services/url-params.interceptor.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { EncodeUrlParamsSafelyInterceptor } from './url-params.interceptor'; - -describe('ServicesInterceptor', () => { - beforeEach(() => - TestBed.configureTestingModule({ - providers: [EncodeUrlParamsSafelyInterceptor], - }), - ); - - it('should be created', () => { - const interceptor: EncodeUrlParamsSafelyInterceptor = TestBed.inject(EncodeUrlParamsSafelyInterceptor); - expect(interceptor).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/services/url-params.interceptor.ts b/frontend/src/app/services/url-params.interceptor.ts deleted file mode 100644 index 0ea7524f4..000000000 --- a/frontend/src/app/services/url-params.interceptor.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - HttpEvent, - HttpHandler, - HttpInterceptor, - HttpParameterCodec, - HttpParams, - HttpRequest, -} from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; - -@Injectable() -export class EncodeUrlParamsSafelyInterceptor implements HttpInterceptor, HttpParameterCodec { - intercept(req: HttpRequest, next: HttpHandler): Observable> { - const newParams = new HttpParams({ - fromString: req.params.toString(), - encoder: this, - }); - - return next.handle( - req.clone({ - params: newParams, - }), - ); - } - - encodeKey(key: string): string { - return encodeURIComponent(key); - } - - encodeValue(value: string): string { - return encodeURIComponent(value); - } - - decodeKey(key: string): string { - return decodeURIComponent(key); - } - - decodeValue(value: string): string { - return decodeURIComponent(value); - } -} diff --git a/frontend/src/app/services/user.service.spec.ts b/frontend/src/app/services/user.service.spec.ts deleted file mode 100644 index 4a0184a7c..000000000 --- a/frontend/src/app/services/user.service.spec.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; -import { AlertActionType, AlertType } from '../models/alert'; -import { NotificationsService } from './notifications.service'; -import { UserService } from './user.service'; - -describe('UserService', () => { - let service: UserService; - let httpMock: HttpTestingController; - let fakeNotifications; - let routerSpy; - - const _authUser = { - email: 'eric.cartman@south.park', - password: '12345678', - }; - - const fakeError = { - message: 'User error', - statusCode: 400, - type: 'no_master_key', - originalMessage: 'User error details', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - showAlert: vi.fn(), - }; - routerSpy = { navigate: vi.fn() }; - - TestBed.configureTestingModule({ - imports: [RouterTestingModule, MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - UserService, - { - provide: NotificationsService, - useValue: fakeNotifications, - }, - { provide: Router, useValue: routerSpy }, - ], - }); - - httpMock = TestBed.inject(HttpTestingController); - service = TestBed.inject(UserService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should call fetchUser', () => { - const user = { - id: '97cbc96d-7cbc-4c8d-b8b8-36322509106d', - isActive: true, - email: 'lyubov+9999@voloshko.com', - createdAt: '2021-01-20T11:17:44.138Z', - subscriptionLevel: 'ANNUAL_ENTERPRISE_PLAN', - }; - let isSubscribeCalled = false; - - service.fetchUser().subscribe((res) => { - expect(res).toEqual(user); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/user`); - expect(req.request.method).toBe('GET'); - req.flush(user); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall fetchUser and show Error snackbar', async () => { - const fetchUser = service.fetchUser().toPromise(); - - const req = httpMock.expectOne(`/user`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchUser; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call requestEmailChange', () => { - let isEmailChangeRequestedCalled = false; - - const requestResponse = { - message: 'Email change request was requested successfully', - }; - - service.requestEmailChange().subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Info, - 'Link has been sent to your email. Please check it.', - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - isEmailChangeRequestedCalled = true; - }); - - const req = httpMock.expectOne(`/user/email/change/request`); - expect(req.request.method).toBe('GET'); - req.flush(requestResponse); - - expect(isEmailChangeRequestedCalled).toBe(true); - }); - - it('should fall for requestEmailChange and show Error alert', async () => { - const resMessage = service.requestEmailChange().toPromise(); - - const req = httpMock.expectOne(`/user/email/change/request`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call changeEmail', () => { - let isEmailChangedCalled = false; - - const requestResponse = { - message: 'Email was changed successfully', - }; - - service.changeEmail('123456789', 'new-new@email.com').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('You email has been changed successfully.'); - isEmailChangedCalled = true; - }); - - const req = httpMock.expectOne(`/user/email/change/verify/123456789`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ email: 'new-new@email.com' }); - req.flush(requestResponse); - - expect(isEmailChangedCalled).toBe(true); - }); - - it('should fall for changeEmail and show Error alert', async () => { - const resMessage = service.changeEmail('123456789', 'new-new@email.com').toPromise(); - - const req = httpMock.expectOne(`/user/email/change/verify/123456789`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call requestPasswordReset and show Success alert', () => { - let isPasswordChangeRequestedCalled = false; - - const requestResponse = { - message: 'Password change request was requested successfully', - }; - - service.requestPasswordReset('john@smith.com', 'company_1').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showAlert).toHaveBeenCalledWith(AlertType.Success, 'Check your email.', [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ]); - isPasswordChangeRequestedCalled = true; - }); - - const req = httpMock.expectOne(`/user/password/reset/request`); - expect(req.request.method).toBe('POST'); - req.flush(requestResponse); - - expect(isPasswordChangeRequestedCalled).toBe(true); - }); - - it('should fall for requestPasswordReset and show Error alert', async () => { - const resMessage = service.requestPasswordReset('john@smith.com', 'company_1').toPromise(); - - const req = httpMock.expectOne(`/user/password/reset/request`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call resetPassword', () => { - let isPasswordChangedCalled = false; - - const requestResponse = { - message: 'Password was changed successfully', - }; - - service.resetPassword('123456789', 'newpassword123').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Your password has been reset successfully.'); - isPasswordChangedCalled = true; - }); - - const req = httpMock.expectOne(`/user/password/reset/verify/123456789`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ password: 'newpassword123' }); - req.flush(requestResponse); - - expect(isPasswordChangedCalled).toBe(true); - }); - - it('should fall for resetPassword and show Error alert', async () => { - const resMessage = service.resetPassword('123456789', 'newpassword123').toPromise(); - - const req = httpMock.expectOne(`/user/password/reset/verify/123456789`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call changePassword', () => { - let isPasswordChangedCalled = false; - - const requestResponse = { - message: 'Password was changed successfully', - }; - - service.changePassword('old-password', 'new-password', 'john@smith.com').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith( - 'Your password has been changed successfully.', - ); - isPasswordChangedCalled = true; - }); - - const req = httpMock.expectOne(`/user/password/change/`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ - email: 'john@smith.com', - oldPassword: 'old-password', - newPassword: 'new-password', - }); - req.flush(requestResponse); - - expect(isPasswordChangedCalled).toBe(true); - }); - - it('should fall for changePassword and show Error alert', async () => { - const resMessage = service.changePassword('old-password', 'new-password', 'john@smith.com').toPromise(); - - const req = httpMock.expectOne(`/user/password/change/`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); - - it('should call changeUserName', () => { - let isChangeUserNameCalled = false; - - const requestResponse = {}; - - service.changeUserName('Eric').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Your name has been changed successfully.'); - isChangeUserNameCalled = true; - }); - - const req = httpMock.expectOne(`/user/name`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ - name: 'Eric', - }); - req.flush(requestResponse); - - expect(isChangeUserNameCalled).toBe(true); - }); - - it('should fall for changeUserName and show Error alert', async () => { - const resMessage = service.changeUserName('Eric').toPromise(); - - const req = httpMock.expectOne(`/user/name`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call switchOn2FA', () => { - let isSwitchOn2FACalled = false; - - const requestResponse = {}; - - service.switchOn2FA().subscribe((res) => { - expect(res).toEqual(requestResponse); - isSwitchOn2FACalled = true; - }); - - const req = httpMock.expectOne(`/user/otp/generate`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({}); - req.flush(requestResponse); - - expect(isSwitchOn2FACalled).toBe(true); - }); - - it('should fall for switchOn2FA and show Error alert', async () => { - const resMessage = service.switchOn2FA().toPromise(); - - const req = httpMock.expectOne(`/user/otp/generate`); - expect(req.request.method).toBe('POST'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call confirm2FA', () => { - let isConfirm2FACalled = false; - - const requestResponse = {}; - - service.confirm2FA('123456').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('2FA is turned on successfully.'); - isConfirm2FACalled = true; - }); - - const req = httpMock.expectOne(`/user/otp/verify`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(requestResponse); - - expect(isConfirm2FACalled).toBe(true); - }); - - it('should fall for confirm2FA and show Error alert', async () => { - const resMessage = service.confirm2FA('123456').toPromise(); - - const req = httpMock.expectOne(`/user/otp/verify`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(fakeError, { status: 400, statusText: '' }); - - try { - await resMessage; - } catch { - // Expected to throw - } - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call switchOff2FA', () => { - let isSwitchOff2FACalled = false; - - const requestResponse = {}; - - service.switchOff2FA('123456').subscribe((res) => { - expect(res).toEqual(requestResponse); - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('2FA is turned off successfully.'); - isSwitchOff2FACalled = true; - }); - - const req = httpMock.expectOne(`/user/otp/disable`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(requestResponse); - - expect(isSwitchOff2FACalled).toBe(true); - }); - - it('should fall for switchOff2FA and show Error alert', async () => { - const resMessage = service.switchOff2FA('123456').toPromise(); - - const req = httpMock.expectOne(`/user/otp/disable`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ otpToken: '123456' }); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call deleteAccount', () => { - let isDeleteuserCalled = false; - const metadata = { - reason: 'missing-features', - message: 'i want to add tables', - }; - const requestResponse = true; - - service.deleteAccount(metadata).subscribe((_res) => { - // expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('You account has been deleted.'); - isDeleteuserCalled = true; - }); - - const req = httpMock.expectOne(`/user/delete`); - expect(req.request.method).toBe('PUT'); - req.flush(requestResponse); - - expect(isDeleteuserCalled).toBe(true); - expect(routerSpy.navigate).toHaveBeenCalledWith(['/deleted']); - }); - - it('should fall for deleteAccount and show Error alert', async () => { - const metadata = { - reason: 'missing-features', - message: 'i want to add tables', - }; - - const resMessage = service.deleteAccount(metadata).toPromise(); - - const req = httpMock.expectOne(`/user/delete`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await resMessage; - - expect(fakeNotifications.showAlert).toHaveBeenCalledWith( - AlertType.Error, - { abstract: fakeError.message, details: fakeError.originalMessage }, - [ - expect.objectContaining({ - type: AlertActionType.Button, - caption: 'Dismiss', - }), - ], - ); - }); -}); diff --git a/frontend/src/app/services/user.service.ts b/frontend/src/app/services/user.service.ts deleted file mode 100644 index d6eeb4f71..000000000 --- a/frontend/src/app/services/user.service.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { BehaviorSubject, EMPTY, throwError } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { AlertActionType, AlertType } from '../models/alert'; -import { CompanyMemberRole } from '../models/company'; -import { ApiKey, SubscriptionPlans, User } from '../models/user'; -import { NotificationsService } from './notifications.service'; - -@Injectable({ - providedIn: 'root', -}) -export class UserService { - public initialUserState: User = { - id: '', - isActive: false, - email: '', - createdAt: '', - subscriptionLevel: SubscriptionPlans.free, - is_2fa_enabled: false, - role: CompanyMemberRole.Member, - externalRegistrationProvider: null, - company: { - id: '', - }, - }; - - private user = new BehaviorSubject(this.initialUserState); - public cast = this.user.asObservable(); - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - public router: Router, - ) {} - - get user$() { - return this.user.asObservable(); - } - - fetchUser() { - return this._http.get('/user').pipe( - map((res) => { - console.log(res); - this.user.next(res); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - sendUserAction(message: string) { - return this._http.post('/action', { message: message }).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - return EMPTY; - }), - ); - } - - requestEmailChange() { - return this._http.get(`/user/email/change/request`).pipe( - map((res) => { - this._notifications.showAlert(AlertType.Info, 'Link has been sent to your email. Please check it.', [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - changeEmail(token: string, newEmail: string) { - return this._http.post(`/user/email/change/verify/${token}`, { email: newEmail }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('You email has been changed successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - requestPasswordReset(email: string, companyId: string) { - return this._http.post(`/user/password/reset/request`, { email, companyId }).pipe( - map((res) => { - this._notifications.showAlert(AlertType.Success, 'Check your email.', [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - resetPassword(token: string, newPassword: string) { - return this._http.post(`/user/password/reset/verify/${token}`, { password: newPassword }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Your password has been reset successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - changePassword(oldPassword: string, newPassword: string, email: string) { - return this._http.post(`/user/password/change/`, { email, oldPassword, newPassword }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Your password has been changed successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - changeUserName(userName: string) { - return this._http.put(`/user/name`, { name: userName }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Your name has been changed successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - switchOn2FA() { - return this._http.post('/user/otp/generate', {}).pipe( - map((res) => { - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - confirm2FA(code: string) { - return this._http.post('/user/otp/verify', { otpToken: code }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('2FA is turned on successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return throwError(() => new Error(err.error?.message || err.message)); - }), - ); - } - - switchOff2FA(code: string) { - return this._http.post('/user/otp/disable', { otpToken: code }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('2FA is turned off successfully.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - getAPIkeys() { - return this._http.get(`/apikeys`).pipe( - map((res) => { - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } - - generateAPIkey(title: string) { - return this._http.post('/apikey', { title }).pipe( - map((res) => { - this._notifications.showSuccessSnackbar(`${title} key is generated successfully.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - deleteAPIkey(apiKey: ApiKey) { - return this._http.delete(`/apikey/${apiKey.id}`).pipe( - map((res) => { - // this.user.next('delete api key'); - this._notifications.showSuccessSnackbar(`${apiKey.title} API key is deleted successfully.`); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - deleteAccount(metadata) { - return this._http.put(`/user/delete`, metadata).pipe( - map(() => { - this.user.next('delete'); - this.router.navigate(['/deleted']); - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/users.service.spec.ts b/frontend/src/app/services/users.service.spec.ts deleted file mode 100644 index d07f29d2b..000000000 --- a/frontend/src/app/services/users.service.spec.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { provideRouter } from '@angular/router'; -import { AccessLevel } from '../models/user'; -import { ApiService } from './api.service'; -import { NotificationsService } from './notifications.service'; -import { UsersService } from './users.service'; - -describe('UsersService', () => { - let service: UsersService; - let httpMock: HttpTestingController; - let fakeNotifications: { showErrorSnackbar: ReturnType; showSuccessSnackbar: ReturnType }; - let mockApi: { - resource: ReturnType; - get: ReturnType; - post: ReturnType; - put: ReturnType; - delete: ReturnType; - }; - - const groupNetwork = { - title: 'Managers', - users: [ - { - id: '83f35e11-6499-470e-9ccb-08b6d9393943', - createdAt: '2021-07-21T14:35:17.270Z', - gclid: null, - isActive: true, - }, - ], - id: '1c042912-326d-4fc5-bb0c-10da88dd37c4', - isMain: false, - }; - - const permissionsNetwork = { - connection: { - connectionId: '75b0574a-9fc5-4472-90e1-5c030b0b28b5', - accessLevel: 'readonly', - }, - group: { - groupId: '1c042912-326d-4fc5-bb0c-10da88dd37c4', - accessLevel: 'edit', - }, - tables: [ - { - tableName: 'TOYS_TEST', - accessLevel: { - visibility: true, - readonly: true, - add: false, - delete: false, - edit: false, - }, - }, - { - tableName: 'PRODUCTS_TEST', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: false, - edit: true, - }, - }, - ], - }; - - const permissionsApp = { - connection: { - accessLevel: AccessLevel.Readonly, - connectionId: '75b0574a-9fc5-4472-90e1-5c030b0b28b5', - }, - group: { - accessLevel: AccessLevel.Edit, - groupId: '1c042912-326d-4fc5-bb0c-10da88dd37c4', - }, - tables: [ - { - accessLevel: { - add: false, - delete: false, - edit: false, - readonly: true, - visibility: true, - }, - tableName: 'TOYS_TEST', - display_name: 'Toys tests', - }, - { - accessLevel: { - add: true, - delete: false, - edit: true, - readonly: false, - visibility: true, - }, - tableName: 'PRODUCTS_TEST', - display_name: 'Product tests', - }, - ], - }; - - const fakeError = { - message: 'Connection error', - statusCode: 400, - type: 'no_master_key', - }; - - beforeEach(() => { - fakeNotifications = { - showErrorSnackbar: vi.fn(), - showSuccessSnackbar: vi.fn(), - }; - - mockApi = { - resource: vi.fn().mockReturnValue({ - value: () => undefined, - isLoading: () => false, - error: () => null, - reload: vi.fn(), - }), - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn(), - }; - - TestBed.configureTestingModule({ - imports: [MatSnackBarModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - provideRouter([]), - { provide: NotificationsService, useValue: fakeNotifications }, - { provide: ApiService, useValue: mockApi }, - ], - }); - - service = TestBed.inject(UsersService); - httpMock = TestBed.inject(HttpTestingController); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - // Signal-based service tests - - it('should set active connection', () => { - service.setActiveConnection('conn-123'); - expect(mockApi.resource).toHaveBeenCalled(); - }); - - it('should create group via ApiService', async () => { - mockApi.post.mockResolvedValue(groupNetwork); - - const result = await service.createGroup('12345678', 'Managers'); - - expect(mockApi.post).toHaveBeenCalledWith( - '/connection/group/12345678', - { title: 'Managers' }, - { successMessage: 'Group of users has been created.' }, - ); - expect(result).toEqual(groupNetwork); - expect(service.groupsUpdated()).toBe('group-added'); - }); - - it('should delete group via ApiService', async () => { - mockApi.delete.mockResolvedValue({}); - - await service.deleteGroup('group12345678'); - - expect(mockApi.delete).toHaveBeenCalledWith('/group/group12345678', { successMessage: 'Group has been removed.' }); - expect(service.groupsUpdated()).toBe('group-deleted'); - }); - - it('should edit group name via ApiService', async () => { - mockApi.put.mockResolvedValue({}); - - await service.editGroupName('group-id', 'New Title'); - - expect(mockApi.put).toHaveBeenCalledWith( - '/group/title', - { title: 'New Title', groupId: 'group-id' }, - { successMessage: 'Group name has been updated.' }, - ); - expect(service.groupsUpdated()).toBe('group-renamed'); - }); - - it('should save cedar policy via ApiService', async () => { - mockApi.post.mockResolvedValue({}); - - await service.saveCedarPolicy('conn-123', 'group-123', 'permit(...)'); - - expect(mockApi.post).toHaveBeenCalledWith( - '/connection/cedar-policy/conn-123', - { cedarPolicy: 'permit(...)', groupId: 'group-123' }, - { successMessage: 'Policy has been saved.' }, - ); - expect(service.groupsUpdated()).toBe('policy-saved'); - }); - - it('should add group user via ApiService', async () => { - mockApi.put.mockResolvedValue({}); - - await service.addGroupUser('group12345678', 'eric.cartman@south.park'); - - expect(mockApi.put).toHaveBeenCalledWith( - '/group/user', - { email: 'eric.cartman@south.park', groupId: 'group12345678' }, - { successMessage: 'User has been added to group.' }, - ); - expect(service.groupsUpdated()).toBe('user-added'); - }); - - it('should delete group user via ApiService', async () => { - mockApi.put.mockResolvedValue({}); - - await service.deleteGroupUser('eric.cartman@south.park', 'group12345678'); - - expect(mockApi.put).toHaveBeenCalledWith( - '/group/user/delete', - { email: 'eric.cartman@south.park', groupId: 'group12345678' }, - { successMessage: 'User has been removed from group.' }, - ); - expect(service.groupsUpdated()).toBe('user-deleted'); - }); - - it('should fetch group users and update signal', async () => { - const mockUsers = [{ id: 'user-1', createdAt: '', gclid: null, isActive: true, email: 'a@b.com' }]; - mockApi.get.mockResolvedValue(mockUsers); - - const result = await service.fetchGroupUsers('group-123'); - - expect(mockApi.get).toHaveBeenCalledWith('/group/users/group-123'); - expect(result).toEqual(mockUsers); - expect(service.groupUsers()['group-123']).toEqual(mockUsers); - }); - - it('should set group users to empty when no users returned', async () => { - mockApi.get.mockResolvedValue([]); - - await service.fetchGroupUsers('group-123'); - - expect(service.groupUsers()['group-123']).toBe('empty'); - }); - - it('should clear groups updated signal', () => { - service.clearGroupsUpdated(); - expect(service.groupsUpdated()).toBe(''); - }); - - // Legacy Observable method tests - - it('should call fetchConnectionUsers', () => { - let isSubscribeCalled = false; - const usersNetwork = [ - { - id: '83f35e11-6499-470e-9ccb-08b6d9393943', - isActive: true, - email: 'lyubov+fghj@voloshko.com', - createdAt: '2021-07-21T14:35:17.270Z', - }, - ]; - - service.fetchConnectionUsers('12345678').subscribe((res) => { - expect(res).toEqual(usersNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(usersNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fail fetchConnectionUsers and show Error snackbar', async () => { - const fetchConnectionUsers = service.fetchConnectionUsers('12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchConnectionUsers; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call fetchPermission', () => { - let isSubscribeCalled = false; - - service.fetchPermission('12345678', 'group12345678').subscribe((res) => { - expect(res).toEqual(permissionsNetwork); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/connection/permissions?connectionId=12345678&groupId=group12345678`); - expect(req.request.method).toBe('GET'); - req.flush(permissionsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fail fetchPermission and show Error snackbar', async () => { - const fetchPermission = service.fetchPermission('12345678', 'group12345678').toPromise(); - - const req = httpMock.expectOne(`/connection/permissions?connectionId=12345678&groupId=group12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchPermission; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call updatePermission and show Success snackbar', () => { - let isSubscribeCalled = false; - - service.updatePermission('12345678', permissionsApp).subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Permissions have been updated successfully.'); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/permissions/1c042912-326d-4fc5-bb0c-10da88dd37c4?connectionId=12345678`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ permissions: permissionsApp }); - req.flush(permissionsNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fail updatePermission and show Error snackbar', async () => { - const updatePermission = service.updatePermission('12345678', permissionsApp).toPromise(); - - const req = httpMock.expectOne(`/permissions/1c042912-326d-4fc5-bb0c-10da88dd37c4?connectionId=12345678`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await updatePermission; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - describe('public permissions', () => { - // ApiService.resource is mocked, so the request factories it was handed are never run. - // Pull them back out and exercise the one that owns the public-permissions URL. - function publicPermissionsUrl(): string | undefined { - for (const [request] of mockApi.resource.mock.calls) { - const url = request(); - if (typeof url === 'string' && url.includes('/connection/public-permissions/')) return url; - } - return undefined; - } - - it('should not request public permissions until opted in', () => { - service.setActiveConnection('conn-a'); - - expect(publicPermissionsUrl()).toBeUndefined(); - }); - - it('should request public permissions once opted in for the active connection', () => { - service.setActiveConnection('conn-a'); - service.loadPublicPermissions('conn-a'); - - expect(publicPermissionsUrl()).toBe('/connection/public-permissions/conn-a'); - }); - - it('should not carry the opt-in over to another connection', () => { - service.setActiveConnection('conn-a'); - service.loadPublicPermissions('conn-a'); - service.setActiveConnection('conn-b'); - - expect(publicPermissionsUrl()).toBeUndefined(); - }); - - it('should save public permissions via ApiService', async () => { - mockApi.put.mockResolvedValue({ enabled: true, tables: [{ tableName: 'users' }] }); - - await service.savePublicPermissions('conn-a', [{ tableName: 'users', readableColumns: ['id', 'name'] }]); - - expect(mockApi.put).toHaveBeenCalledWith( - '/connection/public-permissions/conn-a', - { tables: [{ tableName: 'users', readableColumns: ['id', 'name'] }] }, - { successMessage: 'Public access has been updated.' }, - ); - }); - - it('should send an empty table list to disable public access', async () => { - mockApi.put.mockResolvedValue({ enabled: false, tables: [] }); - - await service.savePublicPermissions('conn-a', []); - - expect(mockApi.put).toHaveBeenCalledWith( - '/connection/public-permissions/conn-a', - { tables: [] }, - { successMessage: 'Public access has been updated.' }, - ); - }); - }); -}); diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts deleted file mode 100644 index 44406f2e5..000000000 --- a/frontend/src/app/services/users.service.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { HttpClient, HttpResourceRef } from '@angular/common/http'; -import { computed, Injectable, inject, signal } from '@angular/core'; -import { catchError, EMPTY, map } from 'rxjs'; -import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items'; -import { groupNameForAction, PERMISSION_GROUP_ORDER } from 'src/app/lib/permission-display'; -import { - GroupUser, - Permissions, - PublicPermissions, - PublicTablePermission, - UserGroup, - UserGroupInfo, -} from 'src/app/models/user'; -import { ApiService } from './api.service'; -import { AuthService } from './auth.service'; -import { NotificationsService } from './notifications.service'; - -export type GroupUpdateEvent = - | 'group-added' - | 'group-deleted' - | 'group-renamed' - | 'policy-saved' - | 'user-added' - | 'user-deleted' - | ''; - -@Injectable({ - providedIn: 'root', -}) -export class UsersService { - private _api = inject(ApiService); - private _auth = inject(AuthService); - private _http = inject(HttpClient); - private _notifications = inject(NotificationsService); - - // Reactive groups fetching - private _activeConnectionId = signal(null); - - private _groupsUpdated = signal(''); - public readonly groupsUpdated = this._groupsUpdated.asReadonly(); - - private _groupsResource: HttpResourceRef = this._api.resource(() => { - const id = this._activeConnectionId(); - if (!id) return undefined; - return `/connection/groups/${id}`; - }); - - public readonly groups = computed(() => { - const raw = this._groupsResource.value() ?? []; - return [...raw].sort((a, b) => { - if (a.group.title === 'Admin') return -1; - if (b.group.title === 'Admin') return 1; - return 0; - }); - }); - public readonly groupsLoading = computed(() => this._groupsResource.isLoading()); - - private _availablePermissionsResource: HttpResourceRef<{ actions: PolicyAction[] } | undefined> = this._api.resource<{ - actions: PolicyAction[]; - }>(() => (this._auth.isAuthenticated() ? '/permissions/available' : undefined)); - - public readonly availablePermissions = computed( - () => this._availablePermissionsResource.value()?.actions ?? [], - ); - - public readonly availablePermissionGroups = computed(() => { - const byGroup = new Map(); - for (const action of this.availablePermissions()) { - const groupName = groupNameForAction(action.value); - const list = byGroup.get(groupName); - if (list) list.push(action); - else byGroup.set(groupName, [action]); - } - return PERMISSION_GROUP_ORDER.filter((name) => byGroup.has(name)).map((name) => ({ - group: name, - actions: byGroup.get(name)!, - })); - }); - - // Public (unauthenticated) access. The endpoint is connection:edit guarded, while - // setActiveConnection is called for every URL carrying a connection id — so the request is - // gated on an explicit opt-in to keep it off pages where the user may lack that permission. - // The opt-in names its connection, so navigating to a different one does not refire the - // request against a connection the user may not administer. - private _publicPermissionsFor = signal(null); - - private _publicPermissionsResource: HttpResourceRef = - this._api.resource(() => { - const id = this._activeConnectionId(); - if (!id || this._publicPermissionsFor() !== id) return undefined; - return `/connection/public-permissions/${id}`; - }); - - public readonly publicPermissions = computed( - () => this._publicPermissionsResource.value() ?? { enabled: false, tables: [] }, - ); - public readonly publicPermissionsLoading = computed(() => this._publicPermissionsResource.isLoading()); - - // Group users - managed imperatively (per-group parallel fetch) - private _groupUsers = signal>({}); - public readonly groupUsers = this._groupUsers.asReadonly(); - - setActiveConnection(id: string): void { - this._activeConnectionId.set(id); - } - - refreshGroups(): void { - this._groupsResource.reload(); - } - - clearGroupsUpdated(): void { - this._groupsUpdated.set(''); - } - - loadPublicPermissions(connectionId: string): void { - this._publicPermissionsFor.set(connectionId); - } - - async savePublicPermissions(connectionId: string, tables: PublicTablePermission[]): Promise { - await this._api.put( - `/connection/public-permissions/${connectionId}`, - { tables }, - { - successMessage: 'Public access has been updated.', - }, - ); - this._publicPermissionsResource.reload(); - } - - async fetchGroupUsers(groupId: string): Promise { - const users = await this._api.get(`/group/users/${groupId}`); - const result = users ?? []; - this._groupUsers.update((current) => ({ - ...current, - [groupId]: result.length ? result : 'empty', - })); - return result; - } - - async fetchAllGroupUsers(groups: UserGroupInfo[]): Promise { - await Promise.all(groups.map((g) => this.fetchGroupUsers(g.group.id))); - } - - // Mutations - async createGroup(connectionId: string, title: string): Promise { - const group = await this._api.post( - `/connection/group/${connectionId}`, - { title }, - { - successMessage: 'Group of users has been created.', - }, - ); - if (group) this._groupsUpdated.set('group-added'); - return group; - } - - async deleteGroup(groupId: string): Promise { - await this._api.delete(`/group/${groupId}`, { - successMessage: 'Group has been removed.', - }); - this._groupsUpdated.set('group-deleted'); - } - - async editGroupName(groupId: string, title: string): Promise { - await this._api.put( - '/group/title', - { title, groupId }, - { - successMessage: 'Group name has been updated.', - }, - ); - this._groupsUpdated.set('group-renamed'); - } - - async saveCedarPolicy(connectionId: string, groupId: string, cedarPolicy: string): Promise { - await this._api.post( - `/connection/cedar-policy/${connectionId}`, - { cedarPolicy, groupId }, - { - successMessage: 'Policy has been saved.', - }, - ); - this.refreshGroups(); - this._groupsUpdated.set('policy-saved'); - } - - async addGroupUser(groupId: string, email: string): Promise { - await this._api.put( - '/group/user', - { email, groupId }, - { - successMessage: 'User has been added to group.', - }, - ); - this._groupsUpdated.set('user-added'); - } - - async deleteGroupUser(email: string, groupId: string): Promise { - await this._api.put( - '/group/user/delete', - { email, groupId }, - { - successMessage: 'User has been removed from group.', - }, - ); - this._groupsUpdated.set('user-deleted'); - } - - // Legacy Observable methods (kept for AuditComponent + permissions UI) - fetchConnectionUsers(connectionID: string) { - return this._http.get(`/connection/users/${connectionID}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - fetchPermission(connectionID: string, groupID: string) { - return this._http - .get(`/connection/permissions`, { - params: { - connectionId: `${connectionID}`, - groupId: `${groupID}`, - }, - }) - .pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - updatePermission(connectionID: string, permissions: Permissions) { - return this._http - .put( - `/permissions/${permissions.group.groupId}`, - { permissions }, - { - params: { connectionId: connectionID }, - }, - ) - .pipe( - map(() => { - this._notifications.showSuccessSnackbar('Permissions have been updated successfully.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } -} diff --git a/frontend/src/app/services/view-as.service.spec.ts b/frontend/src/app/services/view-as.service.spec.ts deleted file mode 100644 index 72fa8a29e..000000000 --- a/frontend/src/app/services/view-as.service.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Location } from '@angular/common'; -import { TestBed } from '@angular/core/testing'; -import { NavigationEnd, Router } from '@angular/router'; -import { Subject } from 'rxjs'; -import { ViewAsService } from './view-as.service'; - -describe('ViewAsService', () => { - let routerEvents$: Subject; - let queryParamValue: string | null; - let routerStub: { - events: Subject; - routerState: { snapshot: { root: { queryParamMap: { get: (key: string) => string | null } } } }; - url: string; - navigate: ReturnType; - }; - let locationStub: { replaceState: ReturnType }; - - function setup(initialUrl: string, initialViewAs: string | null = null): ViewAsService { - queryParamValue = initialViewAs; - routerEvents$ = new Subject(); - routerStub = { - events: routerEvents$, - routerState: { - snapshot: { - root: { - queryParamMap: { - get: (key: string) => (key === 'viewAs' ? queryParamValue : null), - }, - }, - }, - }, - url: initialUrl, - navigate: vi.fn(), - }; - locationStub = { replaceState: vi.fn() }; - - TestBed.configureTestingModule({ - providers: [ - ViewAsService, - { provide: Router, useValue: routerStub }, - { provide: Location, useValue: locationStub }, - ], - }); - - return TestBed.inject(ViewAsService); - } - - function emitNavigationEnd(url: string, newViewAs: string | null) { - queryParamValue = newViewAs; - routerStub.url = url; - routerEvents$.next(new NavigationEnd(1, url, url)); - } - - it('reads initial viewAs from the current URL on construction', () => { - const service = setup('/permissions/abc?viewAs=group-1', 'group-1'); - expect(service.viewAsGroupId()).toBe('group-1'); - expect(service.isActive()).toBe(true); - }); - - it('starts inactive when no viewAs param is present', () => { - const service = setup('/permissions/abc'); - expect(service.viewAsGroupId()).toBe(null); - expect(service.isActive()).toBe(false); - }); - - it('updates the signal when a NavigationEnd delivers a new viewAs', () => { - const service = setup('/permissions/abc'); - emitNavigationEnd('/dashboard/abc?viewAs=group-2', 'group-2'); - expect(service.viewAsGroupId()).toBe('group-2'); - }); - - it('re-injects viewAs into the URL via Location.replaceState when navigation drops it', () => { - const service = setup('/permissions/abc?viewAs=group-1', 'group-1'); - expect(service.viewAsGroupId()).toBe('group-1'); - - emitNavigationEnd('/dashboard/abc', null); - - expect(locationStub.replaceState).toHaveBeenCalledOnce(); - const urlArg = locationStub.replaceState.mock.calls[0][0] as string; - expect(urlArg).toContain('/dashboard/abc'); - expect(urlArg).toContain('viewAs=group-1'); - // Signal stays set — we are persisting it across navigation - expect(service.viewAsGroupId()).toBe('group-1'); - }); - - it('preserves existing query params when re-injecting viewAs', () => { - setup('/?viewAs=group-1', 'group-1'); - - emitNavigationEnd('/dashboard/abc?page=2', null); - - expect(locationStub.replaceState).toHaveBeenCalledOnce(); - const urlArg = locationStub.replaceState.mock.calls[0][0] as string; - expect(urlArg).toContain('page=2'); - expect(urlArg).toContain('viewAs=group-1'); - }); - - it('does not call replaceState when both signal and URL are empty', () => { - setup('/permissions/abc'); - emitNavigationEnd('/dashboard/abc', null); - expect(locationStub.replaceState).not.toHaveBeenCalled(); - }); - - it('clearViewAs resets the signal and routes to drop the param', () => { - const service = setup('/permissions/abc?viewAs=group-1', 'group-1'); - - service.clearViewAs(); - - expect(service.viewAsGroupId()).toBe(null); - expect(routerStub.navigate).toHaveBeenCalledWith( - [], - expect.objectContaining({ - queryParams: { viewAs: null }, - queryParamsHandling: 'merge', - replaceUrl: true, - }), - ); - }); -}); diff --git a/frontend/src/app/services/view-as.service.ts b/frontend/src/app/services/view-as.service.ts deleted file mode 100644 index 80bb242fd..000000000 --- a/frontend/src/app/services/view-as.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Location } from '@angular/common'; -import { computed, Injectable, inject, signal } from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter } from 'rxjs'; - -const VIEW_AS_PARAM = 'viewAs'; - -@Injectable({ providedIn: 'root' }) -export class ViewAsService { - private _router = inject(Router); - private _location = inject(Location); - - private _viewAsGroupId = signal(null); - - public readonly viewAsGroupId = this._viewAsGroupId.asReadonly(); - public readonly isActive = computed(() => !!this._viewAsGroupId()); - - constructor() { - // Initial read on construction (handles deep links / new tabs) - const initial = this._readFromCurrentUrl(); - if (initial) this._viewAsGroupId.set(initial); - - // Keep signal and URL in sync after every navigation - this._router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)).subscribe(() => { - const fromUrl = this._readFromCurrentUrl(); - const fromSignal = this._viewAsGroupId(); - - if (fromUrl) { - if (fromUrl !== fromSignal) this._viewAsGroupId.set(fromUrl); - return; - } - - if (fromSignal) { - // URL was navigated without viewAs but we want to persist it. - // Inject the param back into the URL silently (no new navigation). - this._location.replaceState(this._appendParam(this._router.url, VIEW_AS_PARAM, fromSignal)); - } - }); - } - - clearViewAs(): void { - this._viewAsGroupId.set(null); - this._router.navigate([], { - queryParams: { [VIEW_AS_PARAM]: null }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - } - - private _readFromCurrentUrl(): string | null { - return this._router.routerState.snapshot.root.queryParamMap.get(VIEW_AS_PARAM); - } - - private _appendParam(url: string, key: string, value: string): string { - const [path, query = ''] = url.split('?'); - const params = new URLSearchParams(query); - params.set(key, value); - return `${path}?${params.toString()}`; - } -} diff --git a/frontend/src/app/testing/code-editor.mock.ts b/frontend/src/app/testing/code-editor.mock.ts deleted file mode 100644 index ba659302b..000000000 --- a/frontend/src/app/testing/code-editor.mock.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; - -/** - * Mock component for ngs-code-editor from @ngstack/code-editor. - * - * Usage in tests: - * ```typescript - * import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; - * import { CodeEditorModule } from '@ngstack/code-editor'; - * import { NO_ERRORS_SCHEMA } from '@angular/core'; - * - * await TestBed.configureTestingModule({ - * imports: [YourComponent, BrowserAnimationsModule], - * }) - * .overrideComponent(YourComponent, { - * remove: { imports: [CodeEditorModule] }, - * add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] } - * }) - * .compileComponents(); - * ``` - */ -@Component({ - selector: 'ngs-code-editor', - template: '
', - standalone: true, -}) -export class MockCodeEditorComponent { - @Input() theme: string = 'vs'; - @Input() codeModel: any; - @Input() options: any; - @Input() readOnly: boolean = false; - @Output() valueChanged = new EventEmitter(); -} diff --git a/frontend/src/app/types/turnstile.d.ts b/frontend/src/app/types/turnstile.d.ts deleted file mode 100644 index 15e4a2a50..000000000 --- a/frontend/src/app/types/turnstile.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface TurnstileOptions { - sitekey: string; - callback?: (token: string) => void; - 'error-callback'?: () => void; - 'expired-callback'?: () => void; - theme?: 'light' | 'dark' | 'auto'; - appearance?: 'always' | 'execute' | 'interaction-only'; - size?: 'normal' | 'compact'; -} - -export interface TurnstileInstance { - render: (container: string | HTMLElement, options: TurnstileOptions) => string; - reset: (widgetId?: string) => void; - getResponse: (widgetId?: string) => string | undefined; - remove: (widgetId?: string) => void; -} - -declare global { - interface Window { - turnstile?: TurnstileInstance; - } -} diff --git a/frontend/src/app/validators/base64.validator.ts b/frontend/src/app/validators/base64.validator.ts deleted file mode 100644 index e7c9925e5..000000000 --- a/frontend/src/app/validators/base64.validator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; - -export function base64Validation(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (!control.value) return null; - const value = String(control.value); - const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/; - if (value.length % 4 !== 0 || !base64Regex.test(value)) return { isInvalidBase64: true }; - return null; - }; -} diff --git a/frontend/src/app/validators/connection-string.validator.ts b/frontend/src/app/validators/connection-string.validator.ts deleted file mode 100644 index d2aeec84c..000000000 --- a/frontend/src/app/validators/connection-string.validator.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; -import { ConnectionStringParser } from 'connection-string-parser'; -import { DBtype } from '../models/connection'; - -export interface ParsedConnectionString { - dbType: DBtype; - host: string; - port: string; - username: string; - password: string; - database: string; - authSource?: string; - schema?: string; - ssl?: boolean; -} - -const schemeToDbType: Record = { - mysql: DBtype.MySQL, - mariadb: DBtype.MySQL, - postgres: DBtype.Postgres, - postgresql: DBtype.Postgres, - mongodb: DBtype.Mongo, - 'mongodb+srv': DBtype.Mongo, - mssql: DBtype.MSSQL, - sqlserver: DBtype.MSSQL, - oracle: DBtype.Oracle, - oracledb: DBtype.Oracle, - cassandra: DBtype.Cassandra, - redis: DBtype.Redis, - rediss: DBtype.Redis, - elasticsearch: DBtype.Elasticsearch, - clickhouse: DBtype.ClickHouse, -}; - -const defaultPorts: Record = { - [DBtype.MySQL]: '3306', - [DBtype.Postgres]: '5432', - [DBtype.Oracle]: '1521', - [DBtype.MSSQL]: '1433', - [DBtype.Mongo]: '27017', - [DBtype.Dynamo]: '', - [DBtype.Cassandra]: '9042', - [DBtype.Redis]: '6379', - [DBtype.Elasticsearch]: '9200', - [DBtype.ClickHouse]: '8443', -}; - -export function parseConnectionString(connectionString: string): ParsedConnectionString { - const schemeMatch = connectionString.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//); - if (!schemeMatch) { - throw new Error('invalidFormat'); - } - - const scheme = schemeMatch[1].toLowerCase(); - const dbType = schemeToDbType[scheme]; - if (!dbType) { - throw new Error('unsupportedScheme'); - } - - const parser = new ConnectionStringParser({ scheme, hosts: [] }); - const parsed = parser.parse(connectionString); - - const result: ParsedConnectionString = { - dbType, - host: '', - port: defaultPorts[dbType], - username: '', - password: '', - database: '', - }; - - if (parsed.hosts?.length > 0) { - result.host = parsed.hosts[0].host || ''; - if (parsed.hosts[0].port) { - result.port = String(parsed.hosts[0].port); - } - } - - if (parsed.username) { - result.username = decodeURIComponent(parsed.username); - } - - if (parsed.password) { - result.password = decodeURIComponent(parsed.password); - } - - if (parsed.endpoint) { - result.database = parsed.endpoint; - } - - if (parsed.options) { - if (parsed.options.authSource) { - result.authSource = parsed.options.authSource; - } - if (parsed.options.schema) { - result.schema = parsed.options.schema; - } - if ( - parsed.options.ssl === 'true' || - parsed.options.sslmode === 'require' || - parsed.options.sslmode === 'verify-full' - ) { - result.ssl = true; - } - } - - return result; -} - -export function connectionStringValidation(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const value = control.value as string; - if (!value || !value.trim()) { - return null; - } - - try { - parseConnectionString(value); - return null; - } catch (e) { - if (e.message === 'invalidFormat') { - return { invalidConnectionStringFormat: true }; - } - if (e.message === 'unsupportedScheme') { - const schemeMatch = value.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); - return { unsupportedScheme: schemeMatch?.[1] || true }; - } - return { invalidConnectionString: true }; - } - }; -} diff --git a/frontend/src/app/validators/email.validator.ts b/frontend/src/app/validators/email.validator.ts deleted file mode 100644 index 1f1e90c99..000000000 --- a/frontend/src/app/validators/email.validator.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; - -export function emailValidation(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value) { - const emailRegex = - /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/; - if (!emailRegex.test(control.value)) return { isInvalidEmail: true }; - // don't make return if valid! or return null - } - }; -} diff --git a/frontend/src/app/validators/hex.validator.ts b/frontend/src/app/validators/hex.validator.ts deleted file mode 100644 index 8f6d49048..000000000 --- a/frontend/src/app/validators/hex.validator.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; - -export function hexValidation(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value) { - const hexRegex = /^[-+]?[0-9A-Fa-f]+\.?[0-9A-Fa-f]*?$/; - if (!hexRegex.test(control.value) || !(control.value.length % 2 === 0)) return { isInvalidHex: true }; - // don't make return if valid! or return null - } - }; -} diff --git a/frontend/src/app/validators/hostname.validator.ts b/frontend/src/app/validators/hostname.validator.ts deleted file mode 100644 index 11451448a..000000000 --- a/frontend/src/app/validators/hostname.validator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; -import * as ipaddr from 'ipaddr.js'; -import isFQDN from 'validator/es/lib/isFQDN'; -import isIP from 'validator/es/lib/isIP'; -import { DBtype } from '../models/connection'; - -const PRIVATE_RANGES = new Set(['private', 'loopback', 'linkLocal', 'unspecified', 'carrierGradeNat', 'uniqueLocal']); - -function isPrivateIP(ip: string): boolean { - try { - return PRIVATE_RANGES.has(ipaddr.process(ip).range()); - } catch { - return false; - } -} - -export function hostnameValidation(dbType: DBtype): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value) { - let hostname = control.value as string; - - if (dbType === 'dynamodb') { - if (!hostname.startsWith('https://')) { - return { missingHttps: true }; - } - - hostname = hostname.replace(/^https:\/\//, ''); - } - - if (dbType === 'mongodb') { - hostname = hostname.replace(/^mongodb\+srv:\/\//, ''); - } - - if (control.value === 'localhost' || (isIP(control.value) && isPrivateIP(control.value))) - return { isLocalhost: true }; - if (!(isIP(hostname) || isFQDN(hostname))) return { isInvalidHostname: true }; - } - }; -} diff --git a/frontend/src/app/validators/password.validator.ts b/frontend/src/app/validators/password.validator.ts deleted file mode 100644 index 6f3497d1f..000000000 --- a/frontend/src/app/validators/password.validator.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; - -export function passwordValidation(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value != null && control.value !== '') { - let errors = {} as any; - - errors.oneUpperCaseLetter = /[A-Z]/.test(control.value); - errors.oneNumber = /[0-9]/.test(control.value); - errors.oneLowerCaseLetter = /[a-z]/.test(control.value); - errors.min8 = control.value.length >= 8; - - if (errors.oneUpperCaseLetter && errors.oneNumber && errors.oneLowerCaseLetter && errors.min8) { - return null; // return null if valid! - } else { - return errors; - } - } - }; -} diff --git a/frontend/src/app/validators/text.validator.ts b/frontend/src/app/validators/text.validator.ts deleted file mode 100644 index a6ed2db71..000000000 --- a/frontend/src/app/validators/text.validator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; -import * as validator from 'validator'; - -export function textValidation(validateType: string, regexPattern: string): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (!control.value || control.value === '') { - return null; - } - - if (!validateType) { - return null; - } - - const stringValue = String(control.value); - - // Special case for regex validation - if (validateType === 'regex') { - if (!regexPattern) { - return null; - } - try { - const regex = new RegExp(regexPattern); - if (!regex.test(stringValue)) { - return { invalidPattern: true }; - } - } catch (error) { - console.warn('Invalid regex pattern:', error); - return null; - } - return null; - } - - // Check if validator has this method - // biome-ignore lint: it is expected to import all exports of validator - const validatorMethod = validator[validateType]; - if (typeof validatorMethod !== 'function') { - console.warn(`Unknown validator method: ${validateType}`); - return null; - } - - try { - // Call the validator method - const isValid = validatorMethod(stringValue); - if (!isValid) { - return { [`invalid${validateType}`]: true }; - } - } catch (error) { - console.warn(`Validation error for ${validateType}:`, error); - return null; - } - - return null; - }; -} diff --git a/frontend/src/app/validators/url.validator.ts b/frontend/src/app/validators/url.validator.ts deleted file mode 100644 index 49a5cd7b5..000000000 --- a/frontend/src/app/validators/url.validator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; - -export function urlValidation(prefix: string = ''): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value) { - let url = control.value as string; - - // If there's a prefix, prepend it to the URL for validation - const fullUrl = prefix ? prefix + url : url; - - try { - new URL(fullUrl); - } catch (_e) { - return { isInvalidURL: true }; - } - } - return null; - }; -} diff --git a/frontend/src/app/version.ts b/frontend/src/app/version.ts deleted file mode 100644 index fdb12fe4e..000000000 --- a/frontend/src/app/version.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This file is auto-generated during build -export const version = '1.0.0'; diff --git a/frontend-vue/src/assets/base.css b/frontend/src/assets/base.css similarity index 100% rename from frontend-vue/src/assets/base.css rename to frontend/src/assets/base.css diff --git a/frontend-vue/src/assets/theme.css b/frontend/src/assets/theme.css similarity index 100% rename from frontend-vue/src/assets/theme.css rename to frontend/src/assets/theme.css diff --git a/frontend-vue/src/components/app/AppHeader.spec.ts b/frontend/src/components/app/AppHeader.spec.ts similarity index 100% rename from frontend-vue/src/components/app/AppHeader.spec.ts rename to frontend/src/components/app/AppHeader.spec.ts diff --git a/frontend-vue/src/components/app/AppHeader.vue b/frontend/src/components/app/AppHeader.vue similarity index 100% rename from frontend-vue/src/components/app/AppHeader.vue rename to frontend/src/components/app/AppHeader.vue diff --git a/frontend-vue/src/components/app/AppNavDrawer.vue b/frontend/src/components/app/AppNavDrawer.vue similarity index 100% rename from frontend-vue/src/components/app/AppNavDrawer.vue rename to frontend/src/components/app/AppNavDrawer.vue diff --git a/frontend-vue/src/components/app/AppSnackbars.spec.ts b/frontend/src/components/app/AppSnackbars.spec.ts similarity index 100% rename from frontend-vue/src/components/app/AppSnackbars.spec.ts rename to frontend/src/components/app/AppSnackbars.spec.ts diff --git a/frontend-vue/src/components/app/AppSnackbars.vue b/frontend/src/components/app/AppSnackbars.vue similarity index 100% rename from frontend-vue/src/components/app/AppSnackbars.vue rename to frontend/src/components/app/AppSnackbars.vue diff --git a/frontend-vue/src/components/app/FeatureNotification.vue b/frontend/src/components/app/FeatureNotification.vue similarity index 100% rename from frontend-vue/src/components/app/FeatureNotification.vue rename to frontend/src/components/app/FeatureNotification.vue diff --git a/frontend-vue/src/components/app/ViewAsBanner.vue b/frontend/src/components/app/ViewAsBanner.vue similarity index 100% rename from frontend-vue/src/components/app/ViewAsBanner.vue rename to frontend/src/components/app/ViewAsBanner.vue diff --git a/frontend-vue/src/components/app/navigation.ts b/frontend/src/components/app/navigation.ts similarity index 100% rename from frontend-vue/src/components/app/navigation.ts rename to frontend/src/components/app/navigation.ts diff --git a/frontend-vue/src/components/audit/AuditInfoDialog.spec.ts b/frontend/src/components/audit/AuditInfoDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/audit/AuditInfoDialog.spec.ts rename to frontend/src/components/audit/AuditInfoDialog.spec.ts diff --git a/frontend-vue/src/components/audit/AuditInfoDialog.vue b/frontend/src/components/audit/AuditInfoDialog.vue similarity index 100% rename from frontend-vue/src/components/audit/AuditInfoDialog.vue rename to frontend/src/components/audit/AuditInfoDialog.vue diff --git a/frontend-vue/src/components/audit/useAuditLog.ts b/frontend/src/components/audit/useAuditLog.ts similarity index 100% rename from frontend-vue/src/components/audit/useAuditLog.ts rename to frontend/src/components/audit/useAuditLog.ts diff --git a/frontend-vue/src/components/auth/AuthHeader.vue b/frontend/src/components/auth/AuthHeader.vue similarity index 100% rename from frontend-vue/src/components/auth/AuthHeader.vue rename to frontend/src/components/auth/AuthHeader.vue diff --git a/frontend-vue/src/components/auth/PasswordStrength.spec.ts b/frontend/src/components/auth/PasswordStrength.spec.ts similarity index 100% rename from frontend-vue/src/components/auth/PasswordStrength.spec.ts rename to frontend/src/components/auth/PasswordStrength.spec.ts diff --git a/frontend-vue/src/components/auth/PasswordStrength.vue b/frontend/src/components/auth/PasswordStrength.vue similarity index 100% rename from frontend-vue/src/components/auth/PasswordStrength.vue rename to frontend/src/components/auth/PasswordStrength.vue diff --git a/frontend-vue/src/components/auth/TurnstileWidget.spec.ts b/frontend/src/components/auth/TurnstileWidget.spec.ts similarity index 100% rename from frontend-vue/src/components/auth/TurnstileWidget.spec.ts rename to frontend/src/components/auth/TurnstileWidget.spec.ts diff --git a/frontend-vue/src/components/auth/TurnstileWidget.vue b/frontend/src/components/auth/TurnstileWidget.vue similarity index 100% rename from frontend-vue/src/components/auth/TurnstileWidget.vue rename to frontend/src/components/auth/TurnstileWidget.vue diff --git a/frontend-vue/src/components/auth/UserPassword.spec.ts b/frontend/src/components/auth/UserPassword.spec.ts similarity index 100% rename from frontend-vue/src/components/auth/UserPassword.spec.ts rename to frontend/src/components/auth/UserPassword.spec.ts diff --git a/frontend-vue/src/components/auth/UserPassword.vue b/frontend/src/components/auth/UserPassword.vue similarity index 100% rename from frontend-vue/src/components/auth/UserPassword.vue rename to frontend/src/components/auth/UserPassword.vue diff --git a/frontend-vue/src/components/auth/analytics.ts b/frontend/src/components/auth/analytics.ts similarity index 100% rename from frontend-vue/src/components/auth/analytics.ts rename to frontend/src/components/auth/analytics.ts diff --git a/frontend-vue/src/components/auth/validity.ts b/frontend/src/components/auth/validity.ts similarity index 100% rename from frontend-vue/src/components/auth/validity.ts rename to frontend/src/components/auth/validity.ts diff --git a/frontend-vue/src/components/charts/ChartAdvancedOptions.vue b/frontend/src/components/charts/ChartAdvancedOptions.vue similarity index 100% rename from frontend-vue/src/components/charts/ChartAdvancedOptions.vue rename to frontend/src/components/charts/ChartAdvancedOptions.vue diff --git a/frontend-vue/src/components/charts/ChartDeleteDialog.spec.ts b/frontend/src/components/charts/ChartDeleteDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/charts/ChartDeleteDialog.spec.ts rename to frontend/src/components/charts/ChartDeleteDialog.spec.ts diff --git a/frontend-vue/src/components/charts/ChartDeleteDialog.vue b/frontend/src/components/charts/ChartDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/charts/ChartDeleteDialog.vue rename to frontend/src/components/charts/ChartDeleteDialog.vue diff --git a/frontend-vue/src/components/charts/ChartPreview.spec.ts b/frontend/src/components/charts/ChartPreview.spec.ts similarity index 100% rename from frontend-vue/src/components/charts/ChartPreview.spec.ts rename to frontend/src/components/charts/ChartPreview.spec.ts diff --git a/frontend-vue/src/components/charts/ChartPreview.vue b/frontend/src/components/charts/ChartPreview.vue similarity index 100% rename from frontend-vue/src/components/charts/ChartPreview.vue rename to frontend/src/components/charts/ChartPreview.vue diff --git a/frontend-vue/src/components/charts/ChartSeriesEditor.vue b/frontend/src/components/charts/ChartSeriesEditor.vue similarity index 100% rename from frontend-vue/src/components/charts/ChartSeriesEditor.vue rename to frontend/src/components/charts/ChartSeriesEditor.vue diff --git a/frontend-vue/src/components/charts/__tests__/chart-stub.ts b/frontend/src/components/charts/__tests__/chart-stub.ts similarity index 100% rename from frontend-vue/src/components/charts/__tests__/chart-stub.ts rename to frontend/src/components/charts/__tests__/chart-stub.ts diff --git a/frontend-vue/src/components/charts/chartEditor.spec.ts b/frontend/src/components/charts/chartEditor.spec.ts similarity index 100% rename from frontend-vue/src/components/charts/chartEditor.spec.ts rename to frontend/src/components/charts/chartEditor.spec.ts diff --git a/frontend-vue/src/components/charts/chartEditor.ts b/frontend/src/components/charts/chartEditor.ts similarity index 100% rename from frontend-vue/src/components/charts/chartEditor.ts rename to frontend/src/components/charts/chartEditor.ts diff --git a/frontend-vue/src/components/charts/chartjs.ts b/frontend/src/components/charts/chartjs.ts similarity index 100% rename from frontend-vue/src/components/charts/chartjs.ts rename to frontend/src/components/charts/chartjs.ts diff --git a/frontend-vue/src/components/company/CompanyMemberAccess.vue b/frontend/src/components/company/CompanyMemberAccess.vue similarity index 100% rename from frontend-vue/src/components/company/CompanyMemberAccess.vue rename to frontend/src/components/company/CompanyMemberAccess.vue diff --git a/frontend-vue/src/components/company/CompanyRoleMenu.vue b/frontend/src/components/company/CompanyRoleMenu.vue similarity index 100% rename from frontend-vue/src/components/company/CompanyRoleMenu.vue rename to frontend/src/components/company/CompanyRoleMenu.vue diff --git a/frontend-vue/src/components/company/DeleteDomainDialog.spec.ts b/frontend/src/components/company/DeleteDomainDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/company/DeleteDomainDialog.spec.ts rename to frontend/src/components/company/DeleteDomainDialog.spec.ts diff --git a/frontend-vue/src/components/company/DeleteDomainDialog.vue b/frontend/src/components/company/DeleteDomainDialog.vue similarity index 100% rename from frontend-vue/src/components/company/DeleteDomainDialog.vue rename to frontend/src/components/company/DeleteDomainDialog.vue diff --git a/frontend-vue/src/components/company/DeleteMemberDialog.spec.ts b/frontend/src/components/company/DeleteMemberDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/company/DeleteMemberDialog.spec.ts rename to frontend/src/components/company/DeleteMemberDialog.spec.ts diff --git a/frontend-vue/src/components/company/DeleteMemberDialog.vue b/frontend/src/components/company/DeleteMemberDialog.vue similarity index 100% rename from frontend-vue/src/components/company/DeleteMemberDialog.vue rename to frontend/src/components/company/DeleteMemberDialog.vue diff --git a/frontend-vue/src/components/company/InviteMemberDialog.spec.ts b/frontend/src/components/company/InviteMemberDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/company/InviteMemberDialog.spec.ts rename to frontend/src/components/company/InviteMemberDialog.spec.ts diff --git a/frontend-vue/src/components/company/InviteMemberDialog.vue b/frontend/src/components/company/InviteMemberDialog.vue similarity index 100% rename from frontend-vue/src/components/company/InviteMemberDialog.vue rename to frontend/src/components/company/InviteMemberDialog.vue diff --git a/frontend-vue/src/components/company/RevokeInvitationDialog.spec.ts b/frontend/src/components/company/RevokeInvitationDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/company/RevokeInvitationDialog.spec.ts rename to frontend/src/components/company/RevokeInvitationDialog.spec.ts diff --git a/frontend-vue/src/components/company/RevokeInvitationDialog.vue b/frontend/src/components/company/RevokeInvitationDialog.vue similarity index 100% rename from frontend-vue/src/components/company/RevokeInvitationDialog.vue rename to frontend/src/components/company/RevokeInvitationDialog.vue diff --git a/frontend-vue/src/components/company/companyRoles.ts b/frontend/src/components/company/companyRoles.ts similarity index 100% rename from frontend-vue/src/components/company/companyRoles.ts rename to frontend/src/components/company/companyRoles.ts diff --git a/frontend-vue/src/components/connections/DbConnectionConfirmDialog.spec.ts b/frontend/src/components/connections/DbConnectionConfirmDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionConfirmDialog.spec.ts rename to frontend/src/components/connections/DbConnectionConfirmDialog.spec.ts diff --git a/frontend-vue/src/components/connections/DbConnectionConfirmDialog.vue b/frontend/src/components/connections/DbConnectionConfirmDialog.vue similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionConfirmDialog.vue rename to frontend/src/components/connections/DbConnectionConfirmDialog.vue diff --git a/frontend-vue/src/components/connections/DbConnectionDeleteDialog.spec.ts b/frontend/src/components/connections/DbConnectionDeleteDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionDeleteDialog.spec.ts rename to frontend/src/components/connections/DbConnectionDeleteDialog.spec.ts diff --git a/frontend-vue/src/components/connections/DbConnectionDeleteDialog.vue b/frontend/src/components/connections/DbConnectionDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionDeleteDialog.vue rename to frontend/src/components/connections/DbConnectionDeleteDialog.vue diff --git a/frontend-vue/src/components/connections/DbConnectionIpAccessDialog.spec.ts b/frontend/src/components/connections/DbConnectionIpAccessDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionIpAccessDialog.spec.ts rename to frontend/src/components/connections/DbConnectionIpAccessDialog.spec.ts diff --git a/frontend-vue/src/components/connections/DbConnectionIpAccessDialog.vue b/frontend/src/components/connections/DbConnectionIpAccessDialog.vue similarity index 100% rename from frontend-vue/src/components/connections/DbConnectionIpAccessDialog.vue rename to frontend/src/components/connections/DbConnectionIpAccessDialog.vue diff --git a/frontend-vue/src/components/connections/MasterEncryptionPassword.spec.ts b/frontend/src/components/connections/MasterEncryptionPassword.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/MasterEncryptionPassword.spec.ts rename to frontend/src/components/connections/MasterEncryptionPassword.spec.ts diff --git a/frontend-vue/src/components/connections/MasterEncryptionPassword.vue b/frontend/src/components/connections/MasterEncryptionPassword.vue similarity index 100% rename from frontend-vue/src/components/connections/MasterEncryptionPassword.vue rename to frontend/src/components/connections/MasterEncryptionPassword.vue diff --git a/frontend-vue/src/components/connections/OwnConnections.spec.ts b/frontend/src/components/connections/OwnConnections.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/OwnConnections.spec.ts rename to frontend/src/components/connections/OwnConnections.spec.ts diff --git a/frontend-vue/src/components/connections/OwnConnections.vue b/frontend/src/components/connections/OwnConnections.vue similarity index 100% rename from frontend-vue/src/components/connections/OwnConnections.vue rename to frontend/src/components/connections/OwnConnections.vue diff --git a/frontend-vue/src/components/connections/analytics.ts b/frontend/src/components/connections/analytics.ts similarity index 100% rename from frontend-vue/src/components/connections/analytics.ts rename to frontend/src/components/connections/analytics.ts diff --git a/frontend-vue/src/components/connections/autoConfigureMessages.spec.ts b/frontend/src/components/connections/autoConfigureMessages.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/autoConfigureMessages.spec.ts rename to frontend/src/components/connections/autoConfigureMessages.spec.ts diff --git a/frontend-vue/src/components/connections/autoConfigureMessages.ts b/frontend/src/components/connections/autoConfigureMessages.ts similarity index 100% rename from frontend-vue/src/components/connections/autoConfigureMessages.ts rename to frontend/src/components/connections/autoConfigureMessages.ts diff --git a/frontend-vue/src/components/connections/credentials/CassandraCredentialsForm.vue b/frontend/src/components/connections/credentials/CassandraCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CassandraCredentialsForm.vue rename to frontend/src/components/connections/credentials/CassandraCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/ClickhouseCredentialsForm.vue b/frontend/src/components/connections/credentials/ClickhouseCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/ClickhouseCredentialsForm.vue rename to frontend/src/components/connections/credentials/ClickhouseCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/CredentialsAdvancedSettings.vue b/frontend/src/components/connections/credentials/CredentialsAdvancedSettings.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsAdvancedSettings.vue rename to frontend/src/components/connections/credentials/CredentialsAdvancedSettings.vue diff --git a/frontend-vue/src/components/connections/credentials/CredentialsField.vue b/frontend/src/components/connections/credentials/CredentialsField.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsField.vue rename to frontend/src/components/connections/credentials/CredentialsField.vue diff --git a/frontend-vue/src/components/connections/credentials/CredentialsForms.spec.ts b/frontend/src/components/connections/credentials/CredentialsForms.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsForms.spec.ts rename to frontend/src/components/connections/credentials/CredentialsForms.spec.ts diff --git a/frontend-vue/src/components/connections/credentials/CredentialsHostField.vue b/frontend/src/components/connections/credentials/CredentialsHostField.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsHostField.vue rename to frontend/src/components/connections/credentials/CredentialsHostField.vue diff --git a/frontend-vue/src/components/connections/credentials/CredentialsPasswordField.vue b/frontend/src/components/connections/credentials/CredentialsPasswordField.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsPasswordField.vue rename to frontend/src/components/connections/credentials/CredentialsPasswordField.vue diff --git a/frontend-vue/src/components/connections/credentials/CredentialsTextField.vue b/frontend/src/components/connections/credentials/CredentialsTextField.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/CredentialsTextField.vue rename to frontend/src/components/connections/credentials/CredentialsTextField.vue diff --git a/frontend-vue/src/components/connections/credentials/DynamodbCredentialsForm.vue b/frontend/src/components/connections/credentials/DynamodbCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/DynamodbCredentialsForm.vue rename to frontend/src/components/connections/credentials/DynamodbCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/ElasticCredentialsForm.vue b/frontend/src/components/connections/credentials/ElasticCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/ElasticCredentialsForm.vue rename to frontend/src/components/connections/credentials/ElasticCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/MongodbCredentialsForm.vue b/frontend/src/components/connections/credentials/MongodbCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/MongodbCredentialsForm.vue rename to frontend/src/components/connections/credentials/MongodbCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/MssqlCredentialsForm.vue b/frontend/src/components/connections/credentials/MssqlCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/MssqlCredentialsForm.vue rename to frontend/src/components/connections/credentials/MssqlCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/MysqlCredentialsForm.vue b/frontend/src/components/connections/credentials/MysqlCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/MysqlCredentialsForm.vue rename to frontend/src/components/connections/credentials/MysqlCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/OracledbCredentialsForm.vue b/frontend/src/components/connections/credentials/OracledbCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/OracledbCredentialsForm.vue rename to frontend/src/components/connections/credentials/OracledbCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/PostgresCredentialsForm.vue b/frontend/src/components/connections/credentials/PostgresCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/PostgresCredentialsForm.vue rename to frontend/src/components/connections/credentials/PostgresCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/RedisCredentialsForm.vue b/frontend/src/components/connections/credentials/RedisCredentialsForm.vue similarity index 100% rename from frontend-vue/src/components/connections/credentials/RedisCredentialsForm.vue rename to frontend/src/components/connections/credentials/RedisCredentialsForm.vue diff --git a/frontend-vue/src/components/connections/credentials/credentialsForm.ts b/frontend/src/components/connections/credentials/credentialsForm.ts similarity index 100% rename from frontend-vue/src/components/connections/credentials/credentialsForm.ts rename to frontend/src/components/connections/credentials/credentialsForm.ts diff --git a/frontend-vue/src/components/connections/credentials/fieldPresets.ts b/frontend/src/components/connections/credentials/fieldPresets.ts similarity index 100% rename from frontend-vue/src/components/connections/credentials/fieldPresets.ts rename to frontend/src/components/connections/credentials/fieldPresets.ts diff --git a/frontend-vue/src/components/connections/credentials/index.ts b/frontend/src/components/connections/credentials/index.ts similarity index 100% rename from frontend-vue/src/components/connections/credentials/index.ts rename to frontend/src/components/connections/credentials/index.ts diff --git a/frontend-vue/src/components/connections/dbLogos.ts b/frontend/src/components/connections/dbLogos.ts similarity index 100% rename from frontend-vue/src/components/connections/dbLogos.ts rename to frontend/src/components/connections/dbLogos.ts diff --git a/frontend-vue/src/components/connections/dialogTestUtils.ts b/frontend/src/components/connections/dialogTestUtils.ts similarity index 100% rename from frontend-vue/src/components/connections/dialogTestUtils.ts rename to frontend/src/components/connections/dialogTestUtils.ts diff --git a/frontend-vue/src/components/connections/dialogs.ts b/frontend/src/components/connections/dialogs.ts similarity index 100% rename from frontend-vue/src/components/connections/dialogs.ts rename to frontend/src/components/connections/dialogs.ts diff --git a/frontend-vue/src/components/connections/provider.spec.ts b/frontend/src/components/connections/provider.spec.ts similarity index 100% rename from frontend-vue/src/components/connections/provider.spec.ts rename to frontend/src/components/connections/provider.spec.ts diff --git a/frontend-vue/src/components/connections/provider.ts b/frontend/src/components/connections/provider.ts similarity index 100% rename from frontend-vue/src/components/connections/provider.ts rename to frontend/src/components/connections/provider.ts diff --git a/frontend-vue/src/components/dashboard/ActionLinkDialog.vue b/frontend/src/components/dashboard/ActionLinkDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/ActionLinkDialog.vue rename to frontend/src/components/dashboard/ActionLinkDialog.vue diff --git a/frontend-vue/src/components/dashboard/BulkActionConfirmationDialog.vue b/frontend/src/components/dashboard/BulkActionConfirmationDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/BulkActionConfirmationDialog.vue rename to frontend/src/components/dashboard/BulkActionConfirmationDialog.vue diff --git a/frontend-vue/src/components/dashboard/ColumnsList.vue b/frontend/src/components/dashboard/ColumnsList.vue similarity index 100% rename from frontend-vue/src/components/dashboard/ColumnsList.vue rename to frontend/src/components/dashboard/ColumnsList.vue diff --git a/frontend-vue/src/components/dashboard/FolderDeleteDialog.vue b/frontend/src/components/dashboard/FolderDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/FolderDeleteDialog.vue rename to frontend/src/components/dashboard/FolderDeleteDialog.vue diff --git a/frontend-vue/src/components/dashboard/FolderEditDialog.vue b/frontend/src/components/dashboard/FolderEditDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/FolderEditDialog.vue rename to frontend/src/components/dashboard/FolderEditDialog.vue diff --git a/frontend-vue/src/components/dashboard/SavedFiltersDialog.helpers.ts b/frontend/src/components/dashboard/SavedFiltersDialog.helpers.ts similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersDialog.helpers.ts rename to frontend/src/components/dashboard/SavedFiltersDialog.helpers.ts diff --git a/frontend-vue/src/components/dashboard/SavedFiltersDialog.spec.ts b/frontend/src/components/dashboard/SavedFiltersDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersDialog.spec.ts rename to frontend/src/components/dashboard/SavedFiltersDialog.spec.ts diff --git a/frontend-vue/src/components/dashboard/SavedFiltersDialog.vue b/frontend/src/components/dashboard/SavedFiltersDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersDialog.vue rename to frontend/src/components/dashboard/SavedFiltersDialog.vue diff --git a/frontend-vue/src/components/dashboard/SavedFiltersPanel.helpers.ts b/frontend/src/components/dashboard/SavedFiltersPanel.helpers.ts similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersPanel.helpers.ts rename to frontend/src/components/dashboard/SavedFiltersPanel.helpers.ts diff --git a/frontend-vue/src/components/dashboard/SavedFiltersPanel.spec.ts b/frontend/src/components/dashboard/SavedFiltersPanel.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersPanel.spec.ts rename to frontend/src/components/dashboard/SavedFiltersPanel.spec.ts diff --git a/frontend-vue/src/components/dashboard/SavedFiltersPanel.vue b/frontend/src/components/dashboard/SavedFiltersPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboard/SavedFiltersPanel.vue rename to frontend/src/components/dashboard/SavedFiltersPanel.vue diff --git a/frontend-vue/src/components/dashboard/TableAiPanel.helpers.ts b/frontend/src/components/dashboard/TableAiPanel.helpers.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableAiPanel.helpers.ts rename to frontend/src/components/dashboard/TableAiPanel.helpers.ts diff --git a/frontend-vue/src/components/dashboard/TableAiPanel.spec.ts b/frontend/src/components/dashboard/TableAiPanel.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableAiPanel.spec.ts rename to frontend/src/components/dashboard/TableAiPanel.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableAiPanel.vue b/frontend/src/components/dashboard/TableAiPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableAiPanel.vue rename to frontend/src/components/dashboard/TableAiPanel.vue diff --git a/frontend-vue/src/components/dashboard/TableExportDialog.helpers.ts b/frontend/src/components/dashboard/TableExportDialog.helpers.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableExportDialog.helpers.ts rename to frontend/src/components/dashboard/TableExportDialog.helpers.ts diff --git a/frontend-vue/src/components/dashboard/TableExportDialog.spec.ts b/frontend/src/components/dashboard/TableExportDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableExportDialog.spec.ts rename to frontend/src/components/dashboard/TableExportDialog.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableExportDialog.vue b/frontend/src/components/dashboard/TableExportDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableExportDialog.vue rename to frontend/src/components/dashboard/TableExportDialog.vue diff --git a/frontend-vue/src/components/dashboard/TableFiltersDialog.helpers.ts b/frontend/src/components/dashboard/TableFiltersDialog.helpers.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableFiltersDialog.helpers.ts rename to frontend/src/components/dashboard/TableFiltersDialog.helpers.ts diff --git a/frontend-vue/src/components/dashboard/TableFiltersDialog.spec.ts b/frontend/src/components/dashboard/TableFiltersDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableFiltersDialog.spec.ts rename to frontend/src/components/dashboard/TableFiltersDialog.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableFiltersDialog.vue b/frontend/src/components/dashboard/TableFiltersDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableFiltersDialog.vue rename to frontend/src/components/dashboard/TableFiltersDialog.vue diff --git a/frontend-vue/src/components/dashboard/TableImportDialog.spec.ts b/frontend/src/components/dashboard/TableImportDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableImportDialog.spec.ts rename to frontend/src/components/dashboard/TableImportDialog.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableImportDialog.vue b/frontend/src/components/dashboard/TableImportDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableImportDialog.vue rename to frontend/src/components/dashboard/TableImportDialog.vue diff --git a/frontend-vue/src/components/dashboard/TableRowView.spec.ts b/frontend/src/components/dashboard/TableRowView.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableRowView.spec.ts rename to frontend/src/components/dashboard/TableRowView.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableRowView.vue b/frontend/src/components/dashboard/TableRowView.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableRowView.vue rename to frontend/src/components/dashboard/TableRowView.vue diff --git a/frontend-vue/src/components/dashboard/TableSheet.vue b/frontend/src/components/dashboard/TableSheet.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableSheet.vue rename to frontend/src/components/dashboard/TableSheet.vue diff --git a/frontend-vue/src/components/dashboard/TableView.spec.ts b/frontend/src/components/dashboard/TableView.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TableView.spec.ts rename to frontend/src/components/dashboard/TableView.spec.ts diff --git a/frontend-vue/src/components/dashboard/TableView.vue b/frontend/src/components/dashboard/TableView.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TableView.vue rename to frontend/src/components/dashboard/TableView.vue diff --git a/frontend-vue/src/components/dashboard/TablesList.spec.ts b/frontend/src/components/dashboard/TablesList.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/TablesList.spec.ts rename to frontend/src/components/dashboard/TablesList.spec.ts diff --git a/frontend-vue/src/components/dashboard/TablesList.vue b/frontend/src/components/dashboard/TablesList.vue similarity index 100% rename from frontend-vue/src/components/dashboard/TablesList.vue rename to frontend/src/components/dashboard/TablesList.vue diff --git a/frontend-vue/src/components/dashboard/analytics.ts b/frontend/src/components/dashboard/analytics.ts similarity index 100% rename from frontend-vue/src/components/dashboard/analytics.ts rename to frontend/src/components/dashboard/analytics.ts diff --git a/frontend-vue/src/components/dashboard/backUrl.ts b/frontend/src/components/dashboard/backUrl.ts similarity index 100% rename from frontend-vue/src/components/dashboard/backUrl.ts rename to frontend/src/components/dashboard/backUrl.ts diff --git a/frontend-vue/src/components/dashboard/dialogs.spec.ts b/frontend/src/components/dashboard/dialogs.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboard/dialogs.spec.ts rename to frontend/src/components/dashboard/dialogs.spec.ts diff --git a/frontend-vue/src/components/dashboard/folderColors.ts b/frontend/src/components/dashboard/folderColors.ts similarity index 100% rename from frontend-vue/src/components/dashboard/folderColors.ts rename to frontend/src/components/dashboard/folderColors.ts diff --git a/frontend-vue/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts similarity index 100% rename from frontend-vue/src/components/dashboard/types.ts rename to frontend/src/components/dashboard/types.ts diff --git a/frontend-vue/src/components/dashboards/ChartMiniPreview.vue b/frontend/src/components/dashboards/ChartMiniPreview.vue similarity index 100% rename from frontend-vue/src/components/dashboards/ChartMiniPreview.vue rename to frontend/src/components/dashboards/ChartMiniPreview.vue diff --git a/frontend-vue/src/components/dashboards/DashboardDeleteDialog.vue b/frontend/src/components/dashboards/DashboardDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboards/DashboardDeleteDialog.vue rename to frontend/src/components/dashboards/DashboardDeleteDialog.vue diff --git a/frontend-vue/src/components/dashboards/DashboardEditDialog.spec.ts b/frontend/src/components/dashboards/DashboardEditDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboards/DashboardEditDialog.spec.ts rename to frontend/src/components/dashboards/DashboardEditDialog.spec.ts diff --git a/frontend-vue/src/components/dashboards/DashboardEditDialog.vue b/frontend/src/components/dashboards/DashboardEditDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboards/DashboardEditDialog.vue rename to frontend/src/components/dashboards/DashboardEditDialog.vue diff --git a/frontend-vue/src/components/dashboards/DashboardsSidebar.spec.ts b/frontend/src/components/dashboards/DashboardsSidebar.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboards/DashboardsSidebar.spec.ts rename to frontend/src/components/dashboards/DashboardsSidebar.spec.ts diff --git a/frontend-vue/src/components/dashboards/DashboardsSidebar.vue b/frontend/src/components/dashboards/DashboardsSidebar.vue similarity index 100% rename from frontend-vue/src/components/dashboards/DashboardsSidebar.vue rename to frontend/src/components/dashboards/DashboardsSidebar.vue diff --git a/frontend-vue/src/components/dashboards/DeleteDialogs.spec.ts b/frontend/src/components/dashboards/DeleteDialogs.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboards/DeleteDialogs.spec.ts rename to frontend/src/components/dashboards/DeleteDialogs.spec.ts diff --git a/frontend-vue/src/components/dashboards/DeleteWarning.vue b/frontend/src/components/dashboards/DeleteWarning.vue similarity index 100% rename from frontend-vue/src/components/dashboards/DeleteWarning.vue rename to frontend/src/components/dashboards/DeleteWarning.vue diff --git a/frontend-vue/src/components/dashboards/PanelDeleteDialog.vue b/frontend/src/components/dashboards/PanelDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboards/PanelDeleteDialog.vue rename to frontend/src/components/dashboards/PanelDeleteDialog.vue diff --git a/frontend-vue/src/components/dashboards/PanelEditDialog.spec.ts b/frontend/src/components/dashboards/PanelEditDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboards/PanelEditDialog.spec.ts rename to frontend/src/components/dashboards/PanelEditDialog.spec.ts diff --git a/frontend-vue/src/components/dashboards/PanelEditDialog.vue b/frontend/src/components/dashboards/PanelEditDialog.vue similarity index 100% rename from frontend-vue/src/components/dashboards/PanelEditDialog.vue rename to frontend/src/components/dashboards/PanelEditDialog.vue diff --git a/frontend-vue/src/components/dashboards/__tests__/dialog-harness.ts b/frontend/src/components/dashboards/__tests__/dialog-harness.ts similarity index 100% rename from frontend-vue/src/components/dashboards/__tests__/dialog-harness.ts rename to frontend/src/components/dashboards/__tests__/dialog-harness.ts diff --git a/frontend-vue/src/components/dashboards/__tests__/mount-page.ts b/frontend/src/components/dashboards/__tests__/mount-page.ts similarity index 100% rename from frontend-vue/src/components/dashboards/__tests__/mount-page.ts rename to frontend/src/components/dashboards/__tests__/mount-page.ts diff --git a/frontend-vue/src/components/dashboards/analytics.ts b/frontend/src/components/dashboards/analytics.ts similarity index 100% rename from frontend-vue/src/components/dashboards/analytics.ts rename to frontend/src/components/dashboards/analytics.ts diff --git a/frontend-vue/src/components/dashboards/panels/ChartPanel.vue b/frontend/src/components/dashboards/panels/ChartPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboards/panels/ChartPanel.vue rename to frontend/src/components/dashboards/panels/ChartPanel.vue diff --git a/frontend-vue/src/components/dashboards/panels/CounterPanel.vue b/frontend/src/components/dashboards/panels/CounterPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboards/panels/CounterPanel.vue rename to frontend/src/components/dashboards/panels/CounterPanel.vue diff --git a/frontend-vue/src/components/dashboards/panels/DashboardPanel.vue b/frontend/src/components/dashboards/panels/DashboardPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboards/panels/DashboardPanel.vue rename to frontend/src/components/dashboards/panels/DashboardPanel.vue diff --git a/frontend-vue/src/components/dashboards/panels/TablePanel.vue b/frontend/src/components/dashboards/panels/TablePanel.vue similarity index 100% rename from frontend-vue/src/components/dashboards/panels/TablePanel.vue rename to frontend/src/components/dashboards/panels/TablePanel.vue diff --git a/frontend-vue/src/components/dashboards/panels/TextPanel.vue b/frontend/src/components/dashboards/panels/TextPanel.vue similarity index 100% rename from frontend-vue/src/components/dashboards/panels/TextPanel.vue rename to frontend/src/components/dashboards/panels/TextPanel.vue diff --git a/frontend-vue/src/components/dashboards/panels/panels.spec.ts b/frontend/src/components/dashboards/panels/panels.spec.ts similarity index 100% rename from frontend-vue/src/components/dashboards/panels/panels.spec.ts rename to frontend/src/components/dashboards/panels/panels.spec.ts diff --git a/frontend-vue/src/components/dialog/AppDialog.vue b/frontend/src/components/dialog/AppDialog.vue similarity index 100% rename from frontend-vue/src/components/dialog/AppDialog.vue rename to frontend/src/components/dialog/AppDialog.vue diff --git a/frontend-vue/src/components/dialog/AppDialogForm.vue b/frontend/src/components/dialog/AppDialogForm.vue similarity index 100% rename from frontend-vue/src/components/dialog/AppDialogForm.vue rename to frontend/src/components/dialog/AppDialogForm.vue diff --git a/frontend-vue/src/components/dialog/DialogHost.vue b/frontend/src/components/dialog/DialogHost.vue similarity index 100% rename from frontend-vue/src/components/dialog/DialogHost.vue rename to frontend/src/components/dialog/DialogHost.vue diff --git a/frontend-vue/src/components/dialog/DialogOutlet.vue b/frontend/src/components/dialog/DialogOutlet.vue similarity index 100% rename from frontend-vue/src/components/dialog/DialogOutlet.vue rename to frontend/src/components/dialog/DialogOutlet.vue diff --git a/frontend-vue/src/components/dialog/dialog.spec.ts b/frontend/src/components/dialog/dialog.spec.ts similarity index 100% rename from frontend-vue/src/components/dialog/dialog.spec.ts rename to frontend/src/components/dialog/dialog.spec.ts diff --git a/frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.spec.ts b/frontend/src/components/fields/filter/BinaryDataCaptionFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.spec.ts rename to frontend/src/components/fields/filter/BinaryDataCaptionFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.stories.ts b/frontend/src/components/fields/filter/BinaryDataCaptionFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.stories.ts rename to frontend/src/components/fields/filter/BinaryDataCaptionFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.vue b/frontend/src/components/fields/filter/BinaryDataCaptionFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryDataCaptionFilter.vue rename to frontend/src/components/fields/filter/BinaryDataCaptionFilter.vue diff --git a/frontend-vue/src/components/fields/filter/BinaryFilter.spec.ts b/frontend/src/components/fields/filter/BinaryFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryFilter.spec.ts rename to frontend/src/components/fields/filter/BinaryFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/BinaryFilter.stories.ts b/frontend/src/components/fields/filter/BinaryFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryFilter.stories.ts rename to frontend/src/components/fields/filter/BinaryFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/BinaryFilter.vue b/frontend/src/components/fields/filter/BinaryFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/BinaryFilter.vue rename to frontend/src/components/fields/filter/BinaryFilter.vue diff --git a/frontend-vue/src/components/fields/filter/BooleanFilter.spec.ts b/frontend/src/components/fields/filter/BooleanFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BooleanFilter.spec.ts rename to frontend/src/components/fields/filter/BooleanFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/BooleanFilter.stories.ts b/frontend/src/components/fields/filter/BooleanFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/BooleanFilter.stories.ts rename to frontend/src/components/fields/filter/BooleanFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/BooleanFilter.vue b/frontend/src/components/fields/filter/BooleanFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/BooleanFilter.vue rename to frontend/src/components/fields/filter/BooleanFilter.vue diff --git a/frontend-vue/src/components/fields/filter/CountryFilter.spec.ts b/frontend/src/components/fields/filter/CountryFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/CountryFilter.spec.ts rename to frontend/src/components/fields/filter/CountryFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/CountryFilter.stories.ts b/frontend/src/components/fields/filter/CountryFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/CountryFilter.stories.ts rename to frontend/src/components/fields/filter/CountryFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/CountryFilter.vue b/frontend/src/components/fields/filter/CountryFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/CountryFilter.vue rename to frontend/src/components/fields/filter/CountryFilter.vue diff --git a/frontend-vue/src/components/fields/filter/DateFilter.spec.ts b/frontend/src/components/fields/filter/DateFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DateFilter.spec.ts rename to frontend/src/components/fields/filter/DateFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/DateFilter.stories.ts b/frontend/src/components/fields/filter/DateFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DateFilter.stories.ts rename to frontend/src/components/fields/filter/DateFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/DateFilter.vue b/frontend/src/components/fields/filter/DateFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/DateFilter.vue rename to frontend/src/components/fields/filter/DateFilter.vue diff --git a/frontend-vue/src/components/fields/filter/DateTimeFilter.spec.ts b/frontend/src/components/fields/filter/DateTimeFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DateTimeFilter.spec.ts rename to frontend/src/components/fields/filter/DateTimeFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/DateTimeFilter.stories.ts b/frontend/src/components/fields/filter/DateTimeFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DateTimeFilter.stories.ts rename to frontend/src/components/fields/filter/DateTimeFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/DateTimeFilter.vue b/frontend/src/components/fields/filter/DateTimeFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/DateTimeFilter.vue rename to frontend/src/components/fields/filter/DateTimeFilter.vue diff --git a/frontend-vue/src/components/fields/filter/DefaultFilter.spec.ts b/frontend/src/components/fields/filter/DefaultFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DefaultFilter.spec.ts rename to frontend/src/components/fields/filter/DefaultFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/DefaultFilter.stories.ts b/frontend/src/components/fields/filter/DefaultFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/DefaultFilter.stories.ts rename to frontend/src/components/fields/filter/DefaultFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/DefaultFilter.vue b/frontend/src/components/fields/filter/DefaultFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/DefaultFilter.vue rename to frontend/src/components/fields/filter/DefaultFilter.vue diff --git a/frontend-vue/src/components/fields/filter/EmailFilter.spec.ts b/frontend/src/components/fields/filter/EmailFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/EmailFilter.spec.ts rename to frontend/src/components/fields/filter/EmailFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/EmailFilter.stories.ts b/frontend/src/components/fields/filter/EmailFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/EmailFilter.stories.ts rename to frontend/src/components/fields/filter/EmailFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/EmailFilter.vue b/frontend/src/components/fields/filter/EmailFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/EmailFilter.vue rename to frontend/src/components/fields/filter/EmailFilter.vue diff --git a/frontend-vue/src/components/fields/filter/FILTER_WIDGETS.md b/frontend/src/components/fields/filter/FILTER_WIDGETS.md similarity index 100% rename from frontend-vue/src/components/fields/filter/FILTER_WIDGETS.md rename to frontend/src/components/fields/filter/FILTER_WIDGETS.md diff --git a/frontend-vue/src/components/fields/filter/FileFilter.spec.ts b/frontend/src/components/fields/filter/FileFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/FileFilter.spec.ts rename to frontend/src/components/fields/filter/FileFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/FileFilter.stories.ts b/frontend/src/components/fields/filter/FileFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/FileFilter.stories.ts rename to frontend/src/components/fields/filter/FileFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/FileFilter.vue b/frontend/src/components/fields/filter/FileFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/FileFilter.vue rename to frontend/src/components/fields/filter/FileFilter.vue diff --git a/frontend-vue/src/components/fields/filter/FilterComparatorRow.vue b/frontend/src/components/fields/filter/FilterComparatorRow.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/FilterComparatorRow.vue rename to frontend/src/components/fields/filter/FilterComparatorRow.vue diff --git a/frontend-vue/src/components/fields/filter/ForeignKeyFilter.spec.ts b/frontend/src/components/fields/filter/ForeignKeyFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/ForeignKeyFilter.spec.ts rename to frontend/src/components/fields/filter/ForeignKeyFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/ForeignKeyFilter.stories.ts b/frontend/src/components/fields/filter/ForeignKeyFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/ForeignKeyFilter.stories.ts rename to frontend/src/components/fields/filter/ForeignKeyFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/ForeignKeyFilter.vue b/frontend/src/components/fields/filter/ForeignKeyFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/ForeignKeyFilter.vue rename to frontend/src/components/fields/filter/ForeignKeyFilter.vue diff --git a/frontend-vue/src/components/fields/filter/IdFilter.spec.ts b/frontend/src/components/fields/filter/IdFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/IdFilter.spec.ts rename to frontend/src/components/fields/filter/IdFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/IdFilter.stories.ts b/frontend/src/components/fields/filter/IdFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/IdFilter.stories.ts rename to frontend/src/components/fields/filter/IdFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/IdFilter.vue b/frontend/src/components/fields/filter/IdFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/IdFilter.vue rename to frontend/src/components/fields/filter/IdFilter.vue diff --git a/frontend-vue/src/components/fields/filter/JsonEditorFilter.spec.ts b/frontend/src/components/fields/filter/JsonEditorFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/JsonEditorFilter.spec.ts rename to frontend/src/components/fields/filter/JsonEditorFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/JsonEditorFilter.stories.ts b/frontend/src/components/fields/filter/JsonEditorFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/JsonEditorFilter.stories.ts rename to frontend/src/components/fields/filter/JsonEditorFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/JsonEditorFilter.vue b/frontend/src/components/fields/filter/JsonEditorFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/JsonEditorFilter.vue rename to frontend/src/components/fields/filter/JsonEditorFilter.vue diff --git a/frontend-vue/src/components/fields/filter/LongTextFilter.spec.ts b/frontend/src/components/fields/filter/LongTextFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/LongTextFilter.spec.ts rename to frontend/src/components/fields/filter/LongTextFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/LongTextFilter.stories.ts b/frontend/src/components/fields/filter/LongTextFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/LongTextFilter.stories.ts rename to frontend/src/components/fields/filter/LongTextFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/LongTextFilter.vue b/frontend/src/components/fields/filter/LongTextFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/LongTextFilter.vue rename to frontend/src/components/fields/filter/LongTextFilter.vue diff --git a/frontend-vue/src/components/fields/filter/NumberFilter.spec.ts b/frontend/src/components/fields/filter/NumberFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/NumberFilter.spec.ts rename to frontend/src/components/fields/filter/NumberFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/NumberFilter.stories.ts b/frontend/src/components/fields/filter/NumberFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/NumberFilter.stories.ts rename to frontend/src/components/fields/filter/NumberFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/NumberFilter.vue b/frontend/src/components/fields/filter/NumberFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/NumberFilter.vue rename to frontend/src/components/fields/filter/NumberFilter.vue diff --git a/frontend-vue/src/components/fields/filter/PasswordFilter.spec.ts b/frontend/src/components/fields/filter/PasswordFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PasswordFilter.spec.ts rename to frontend/src/components/fields/filter/PasswordFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/PasswordFilter.stories.ts b/frontend/src/components/fields/filter/PasswordFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PasswordFilter.stories.ts rename to frontend/src/components/fields/filter/PasswordFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/PasswordFilter.vue b/frontend/src/components/fields/filter/PasswordFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/PasswordFilter.vue rename to frontend/src/components/fields/filter/PasswordFilter.vue diff --git a/frontend-vue/src/components/fields/filter/PhoneFilter.spec.ts b/frontend/src/components/fields/filter/PhoneFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PhoneFilter.spec.ts rename to frontend/src/components/fields/filter/PhoneFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/PhoneFilter.stories.ts b/frontend/src/components/fields/filter/PhoneFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PhoneFilter.stories.ts rename to frontend/src/components/fields/filter/PhoneFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/PhoneFilter.vue b/frontend/src/components/fields/filter/PhoneFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/PhoneFilter.vue rename to frontend/src/components/fields/filter/PhoneFilter.vue diff --git a/frontend-vue/src/components/fields/filter/PointFilter.spec.ts b/frontend/src/components/fields/filter/PointFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PointFilter.spec.ts rename to frontend/src/components/fields/filter/PointFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/PointFilter.stories.ts b/frontend/src/components/fields/filter/PointFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/PointFilter.stories.ts rename to frontend/src/components/fields/filter/PointFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/PointFilter.vue b/frontend/src/components/fields/filter/PointFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/PointFilter.vue rename to frontend/src/components/fields/filter/PointFilter.vue diff --git a/frontend-vue/src/components/fields/filter/SelectFilter.spec.ts b/frontend/src/components/fields/filter/SelectFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/SelectFilter.spec.ts rename to frontend/src/components/fields/filter/SelectFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/SelectFilter.stories.ts b/frontend/src/components/fields/filter/SelectFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/SelectFilter.stories.ts rename to frontend/src/components/fields/filter/SelectFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/SelectFilter.vue b/frontend/src/components/fields/filter/SelectFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/SelectFilter.vue rename to frontend/src/components/fields/filter/SelectFilter.vue diff --git a/frontend-vue/src/components/fields/filter/StaticTextFilter.spec.ts b/frontend/src/components/fields/filter/StaticTextFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/StaticTextFilter.spec.ts rename to frontend/src/components/fields/filter/StaticTextFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/StaticTextFilter.stories.ts b/frontend/src/components/fields/filter/StaticTextFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/StaticTextFilter.stories.ts rename to frontend/src/components/fields/filter/StaticTextFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/StaticTextFilter.vue b/frontend/src/components/fields/filter/StaticTextFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/StaticTextFilter.vue rename to frontend/src/components/fields/filter/StaticTextFilter.vue diff --git a/frontend-vue/src/components/fields/filter/TextFilter.spec.ts b/frontend/src/components/fields/filter/TextFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TextFilter.spec.ts rename to frontend/src/components/fields/filter/TextFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/TextFilter.stories.ts b/frontend/src/components/fields/filter/TextFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TextFilter.stories.ts rename to frontend/src/components/fields/filter/TextFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/TextFilter.vue b/frontend/src/components/fields/filter/TextFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/TextFilter.vue rename to frontend/src/components/fields/filter/TextFilter.vue diff --git a/frontend-vue/src/components/fields/filter/TimeFilter.spec.ts b/frontend/src/components/fields/filter/TimeFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeFilter.spec.ts rename to frontend/src/components/fields/filter/TimeFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/TimeFilter.stories.ts b/frontend/src/components/fields/filter/TimeFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeFilter.stories.ts rename to frontend/src/components/fields/filter/TimeFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/TimeFilter.vue b/frontend/src/components/fields/filter/TimeFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeFilter.vue rename to frontend/src/components/fields/filter/TimeFilter.vue diff --git a/frontend-vue/src/components/fields/filter/TimeIntervalFilter.spec.ts b/frontend/src/components/fields/filter/TimeIntervalFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeIntervalFilter.spec.ts rename to frontend/src/components/fields/filter/TimeIntervalFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/TimeIntervalFilter.stories.ts b/frontend/src/components/fields/filter/TimeIntervalFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeIntervalFilter.stories.ts rename to frontend/src/components/fields/filter/TimeIntervalFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/TimeIntervalFilter.vue b/frontend/src/components/fields/filter/TimeIntervalFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/TimeIntervalFilter.vue rename to frontend/src/components/fields/filter/TimeIntervalFilter.vue diff --git a/frontend-vue/src/components/fields/filter/TimezoneFilter.spec.ts b/frontend/src/components/fields/filter/TimezoneFilter.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimezoneFilter.spec.ts rename to frontend/src/components/fields/filter/TimezoneFilter.spec.ts diff --git a/frontend-vue/src/components/fields/filter/TimezoneFilter.stories.ts b/frontend/src/components/fields/filter/TimezoneFilter.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/TimezoneFilter.stories.ts rename to frontend/src/components/fields/filter/TimezoneFilter.stories.ts diff --git a/frontend-vue/src/components/fields/filter/TimezoneFilter.vue b/frontend/src/components/fields/filter/TimezoneFilter.vue similarity index 100% rename from frontend-vue/src/components/fields/filter/TimezoneFilter.vue rename to frontend/src/components/fields/filter/TimezoneFilter.vue diff --git a/frontend-vue/src/components/fields/filter/comparatorType.spec.ts b/frontend/src/components/fields/filter/comparatorType.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/comparatorType.spec.ts rename to frontend/src/components/fields/filter/comparatorType.spec.ts diff --git a/frontend-vue/src/components/fields/filter/comparatorType.ts b/frontend/src/components/fields/filter/comparatorType.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/comparatorType.ts rename to frontend/src/components/fields/filter/comparatorType.ts diff --git a/frontend-vue/src/components/fields/filter/filterTestUtils.ts b/frontend/src/components/fields/filter/filterTestUtils.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/filterTestUtils.ts rename to frontend/src/components/fields/filter/filterTestUtils.ts diff --git a/frontend-vue/src/components/fields/filter/timezones.ts b/frontend/src/components/fields/filter/timezones.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/timezones.ts rename to frontend/src/components/fields/filter/timezones.ts diff --git a/frontend-vue/src/components/fields/filter/useAutofocus.ts b/frontend/src/components/fields/filter/useAutofocus.ts similarity index 100% rename from frontend-vue/src/components/fields/filter/useAutofocus.ts rename to frontend/src/components/fields/filter/useAutofocus.ts diff --git a/frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.spec.ts b/frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.spec.ts rename to frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.stories.ts b/frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.stories.ts rename to frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.vue b/frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryDataCaptionEdit.vue rename to frontend/src/components/fields/record-edit/BinaryDataCaptionEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/BinaryEdit.spec.ts b/frontend/src/components/fields/record-edit/BinaryEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryEdit.spec.ts rename to frontend/src/components/fields/record-edit/BinaryEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/BinaryEdit.stories.ts b/frontend/src/components/fields/record-edit/BinaryEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryEdit.stories.ts rename to frontend/src/components/fields/record-edit/BinaryEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/BinaryEdit.vue b/frontend/src/components/fields/record-edit/BinaryEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BinaryEdit.vue rename to frontend/src/components/fields/record-edit/BinaryEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/BooleanEdit.spec.ts b/frontend/src/components/fields/record-edit/BooleanEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BooleanEdit.spec.ts rename to frontend/src/components/fields/record-edit/BooleanEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/BooleanEdit.stories.ts b/frontend/src/components/fields/record-edit/BooleanEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BooleanEdit.stories.ts rename to frontend/src/components/fields/record-edit/BooleanEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/BooleanEdit.vue b/frontend/src/components/fields/record-edit/BooleanEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/BooleanEdit.vue rename to frontend/src/components/fields/record-edit/BooleanEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/CodeEdit.spec.ts b/frontend/src/components/fields/record-edit/CodeEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CodeEdit.spec.ts rename to frontend/src/components/fields/record-edit/CodeEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/CodeEdit.stories.ts b/frontend/src/components/fields/record-edit/CodeEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CodeEdit.stories.ts rename to frontend/src/components/fields/record-edit/CodeEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/CodeEdit.vue b/frontend/src/components/fields/record-edit/CodeEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CodeEdit.vue rename to frontend/src/components/fields/record-edit/CodeEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/ColorEdit.spec.ts b/frontend/src/components/fields/record-edit/ColorEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ColorEdit.spec.ts rename to frontend/src/components/fields/record-edit/ColorEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/ColorEdit.stories.ts b/frontend/src/components/fields/record-edit/ColorEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ColorEdit.stories.ts rename to frontend/src/components/fields/record-edit/ColorEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/ColorEdit.vue b/frontend/src/components/fields/record-edit/ColorEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ColorEdit.vue rename to frontend/src/components/fields/record-edit/ColorEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/CountryEdit.spec.ts b/frontend/src/components/fields/record-edit/CountryEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CountryEdit.spec.ts rename to frontend/src/components/fields/record-edit/CountryEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/CountryEdit.stories.ts b/frontend/src/components/fields/record-edit/CountryEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CountryEdit.stories.ts rename to frontend/src/components/fields/record-edit/CountryEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/CountryEdit.vue b/frontend/src/components/fields/record-edit/CountryEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/CountryEdit.vue rename to frontend/src/components/fields/record-edit/CountryEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/DateEdit.spec.ts b/frontend/src/components/fields/record-edit/DateEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateEdit.spec.ts rename to frontend/src/components/fields/record-edit/DateEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/DateEdit.stories.ts b/frontend/src/components/fields/record-edit/DateEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateEdit.stories.ts rename to frontend/src/components/fields/record-edit/DateEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/DateEdit.vue b/frontend/src/components/fields/record-edit/DateEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateEdit.vue rename to frontend/src/components/fields/record-edit/DateEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/DateTimeEdit.spec.ts b/frontend/src/components/fields/record-edit/DateTimeEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateTimeEdit.spec.ts rename to frontend/src/components/fields/record-edit/DateTimeEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/DateTimeEdit.stories.ts b/frontend/src/components/fields/record-edit/DateTimeEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateTimeEdit.stories.ts rename to frontend/src/components/fields/record-edit/DateTimeEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/DateTimeEdit.vue b/frontend/src/components/fields/record-edit/DateTimeEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/DateTimeEdit.vue rename to frontend/src/components/fields/record-edit/DateTimeEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/FileEdit.spec.ts b/frontend/src/components/fields/record-edit/FileEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/FileEdit.spec.ts rename to frontend/src/components/fields/record-edit/FileEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/FileEdit.stories.ts b/frontend/src/components/fields/record-edit/FileEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/FileEdit.stories.ts rename to frontend/src/components/fields/record-edit/FileEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/FileEdit.vue b/frontend/src/components/fields/record-edit/FileEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/FileEdit.vue rename to frontend/src/components/fields/record-edit/FileEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.spec.ts b/frontend/src/components/fields/record-edit/ForeignKeyEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.spec.ts rename to frontend/src/components/fields/record-edit/ForeignKeyEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.stories.ts b/frontend/src/components/fields/record-edit/ForeignKeyEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.stories.ts rename to frontend/src/components/fields/record-edit/ForeignKeyEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.vue b/frontend/src/components/fields/record-edit/ForeignKeyEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ForeignKeyEdit.vue rename to frontend/src/components/fields/record-edit/ForeignKeyEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/IdEdit.spec.ts b/frontend/src/components/fields/record-edit/IdEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/IdEdit.spec.ts rename to frontend/src/components/fields/record-edit/IdEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/IdEdit.stories.ts b/frontend/src/components/fields/record-edit/IdEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/IdEdit.stories.ts rename to frontend/src/components/fields/record-edit/IdEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/IdEdit.vue b/frontend/src/components/fields/record-edit/IdEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/IdEdit.vue rename to frontend/src/components/fields/record-edit/IdEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/ImageEdit.spec.ts b/frontend/src/components/fields/record-edit/ImageEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ImageEdit.spec.ts rename to frontend/src/components/fields/record-edit/ImageEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/ImageEdit.stories.ts b/frontend/src/components/fields/record-edit/ImageEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ImageEdit.stories.ts rename to frontend/src/components/fields/record-edit/ImageEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/ImageEdit.vue b/frontend/src/components/fields/record-edit/ImageEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/ImageEdit.vue rename to frontend/src/components/fields/record-edit/ImageEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/JsonEditorEdit.spec.ts b/frontend/src/components/fields/record-edit/JsonEditorEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/JsonEditorEdit.spec.ts rename to frontend/src/components/fields/record-edit/JsonEditorEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/JsonEditorEdit.stories.ts b/frontend/src/components/fields/record-edit/JsonEditorEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/JsonEditorEdit.stories.ts rename to frontend/src/components/fields/record-edit/JsonEditorEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/JsonEditorEdit.vue b/frontend/src/components/fields/record-edit/JsonEditorEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/JsonEditorEdit.vue rename to frontend/src/components/fields/record-edit/JsonEditorEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/LanguageEdit.spec.ts b/frontend/src/components/fields/record-edit/LanguageEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LanguageEdit.spec.ts rename to frontend/src/components/fields/record-edit/LanguageEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/LanguageEdit.stories.ts b/frontend/src/components/fields/record-edit/LanguageEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LanguageEdit.stories.ts rename to frontend/src/components/fields/record-edit/LanguageEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/LanguageEdit.vue b/frontend/src/components/fields/record-edit/LanguageEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LanguageEdit.vue rename to frontend/src/components/fields/record-edit/LanguageEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/LongTextEdit.spec.ts b/frontend/src/components/fields/record-edit/LongTextEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LongTextEdit.spec.ts rename to frontend/src/components/fields/record-edit/LongTextEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/LongTextEdit.stories.ts b/frontend/src/components/fields/record-edit/LongTextEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LongTextEdit.stories.ts rename to frontend/src/components/fields/record-edit/LongTextEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/LongTextEdit.vue b/frontend/src/components/fields/record-edit/LongTextEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/LongTextEdit.vue rename to frontend/src/components/fields/record-edit/LongTextEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/MarkdownEdit.spec.ts b/frontend/src/components/fields/record-edit/MarkdownEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MarkdownEdit.spec.ts rename to frontend/src/components/fields/record-edit/MarkdownEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/MarkdownEdit.stories.ts b/frontend/src/components/fields/record-edit/MarkdownEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MarkdownEdit.stories.ts rename to frontend/src/components/fields/record-edit/MarkdownEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/MarkdownEdit.vue b/frontend/src/components/fields/record-edit/MarkdownEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MarkdownEdit.vue rename to frontend/src/components/fields/record-edit/MarkdownEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/MoneyEdit.spec.ts b/frontend/src/components/fields/record-edit/MoneyEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MoneyEdit.spec.ts rename to frontend/src/components/fields/record-edit/MoneyEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/MoneyEdit.stories.ts b/frontend/src/components/fields/record-edit/MoneyEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MoneyEdit.stories.ts rename to frontend/src/components/fields/record-edit/MoneyEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/MoneyEdit.vue b/frontend/src/components/fields/record-edit/MoneyEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/MoneyEdit.vue rename to frontend/src/components/fields/record-edit/MoneyEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/NumberEdit.spec.ts b/frontend/src/components/fields/record-edit/NumberEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/NumberEdit.spec.ts rename to frontend/src/components/fields/record-edit/NumberEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/NumberEdit.stories.ts b/frontend/src/components/fields/record-edit/NumberEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/NumberEdit.stories.ts rename to frontend/src/components/fields/record-edit/NumberEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/NumberEdit.vue b/frontend/src/components/fields/record-edit/NumberEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/NumberEdit.vue rename to frontend/src/components/fields/record-edit/NumberEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/PasswordEdit.spec.ts b/frontend/src/components/fields/record-edit/PasswordEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PasswordEdit.spec.ts rename to frontend/src/components/fields/record-edit/PasswordEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/PasswordEdit.stories.ts b/frontend/src/components/fields/record-edit/PasswordEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PasswordEdit.stories.ts rename to frontend/src/components/fields/record-edit/PasswordEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/PasswordEdit.vue b/frontend/src/components/fields/record-edit/PasswordEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PasswordEdit.vue rename to frontend/src/components/fields/record-edit/PasswordEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/PhoneEdit.countries.ts b/frontend/src/components/fields/record-edit/PhoneEdit.countries.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PhoneEdit.countries.ts rename to frontend/src/components/fields/record-edit/PhoneEdit.countries.ts diff --git a/frontend-vue/src/components/fields/record-edit/PhoneEdit.spec.ts b/frontend/src/components/fields/record-edit/PhoneEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PhoneEdit.spec.ts rename to frontend/src/components/fields/record-edit/PhoneEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/PhoneEdit.stories.ts b/frontend/src/components/fields/record-edit/PhoneEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PhoneEdit.stories.ts rename to frontend/src/components/fields/record-edit/PhoneEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/PhoneEdit.vue b/frontend/src/components/fields/record-edit/PhoneEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PhoneEdit.vue rename to frontend/src/components/fields/record-edit/PhoneEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/PointEdit.spec.ts b/frontend/src/components/fields/record-edit/PointEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PointEdit.spec.ts rename to frontend/src/components/fields/record-edit/PointEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/PointEdit.stories.ts b/frontend/src/components/fields/record-edit/PointEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PointEdit.stories.ts rename to frontend/src/components/fields/record-edit/PointEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/PointEdit.vue b/frontend/src/components/fields/record-edit/PointEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/PointEdit.vue rename to frontend/src/components/fields/record-edit/PointEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/RangeEdit.spec.ts b/frontend/src/components/fields/record-edit/RangeEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/RangeEdit.spec.ts rename to frontend/src/components/fields/record-edit/RangeEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/RangeEdit.stories.ts b/frontend/src/components/fields/record-edit/RangeEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/RangeEdit.stories.ts rename to frontend/src/components/fields/record-edit/RangeEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/RangeEdit.vue b/frontend/src/components/fields/record-edit/RangeEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/RangeEdit.vue rename to frontend/src/components/fields/record-edit/RangeEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/S3Edit.spec.ts b/frontend/src/components/fields/record-edit/S3Edit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/S3Edit.spec.ts rename to frontend/src/components/fields/record-edit/S3Edit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/S3Edit.stories.ts b/frontend/src/components/fields/record-edit/S3Edit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/S3Edit.stories.ts rename to frontend/src/components/fields/record-edit/S3Edit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/S3Edit.vue b/frontend/src/components/fields/record-edit/S3Edit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/S3Edit.vue rename to frontend/src/components/fields/record-edit/S3Edit.vue diff --git a/frontend-vue/src/components/fields/record-edit/SelectEdit.spec.ts b/frontend/src/components/fields/record-edit/SelectEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/SelectEdit.spec.ts rename to frontend/src/components/fields/record-edit/SelectEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/SelectEdit.stories.ts b/frontend/src/components/fields/record-edit/SelectEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/SelectEdit.stories.ts rename to frontend/src/components/fields/record-edit/SelectEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/SelectEdit.vue b/frontend/src/components/fields/record-edit/SelectEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/SelectEdit.vue rename to frontend/src/components/fields/record-edit/SelectEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/StaticTextEdit.spec.ts b/frontend/src/components/fields/record-edit/StaticTextEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/StaticTextEdit.spec.ts rename to frontend/src/components/fields/record-edit/StaticTextEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/StaticTextEdit.stories.ts b/frontend/src/components/fields/record-edit/StaticTextEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/StaticTextEdit.stories.ts rename to frontend/src/components/fields/record-edit/StaticTextEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/StaticTextEdit.vue b/frontend/src/components/fields/record-edit/StaticTextEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/StaticTextEdit.vue rename to frontend/src/components/fields/record-edit/StaticTextEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/TextEdit.spec.ts b/frontend/src/components/fields/record-edit/TextEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TextEdit.spec.ts rename to frontend/src/components/fields/record-edit/TextEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/TextEdit.stories.ts b/frontend/src/components/fields/record-edit/TextEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TextEdit.stories.ts rename to frontend/src/components/fields/record-edit/TextEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/TextEdit.vue b/frontend/src/components/fields/record-edit/TextEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TextEdit.vue rename to frontend/src/components/fields/record-edit/TextEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/TimeEdit.spec.ts b/frontend/src/components/fields/record-edit/TimeEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeEdit.spec.ts rename to frontend/src/components/fields/record-edit/TimeEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimeEdit.stories.ts b/frontend/src/components/fields/record-edit/TimeEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeEdit.stories.ts rename to frontend/src/components/fields/record-edit/TimeEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimeEdit.vue b/frontend/src/components/fields/record-edit/TimeEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeEdit.vue rename to frontend/src/components/fields/record-edit/TimeEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.spec.ts b/frontend/src/components/fields/record-edit/TimeIntervalEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.spec.ts rename to frontend/src/components/fields/record-edit/TimeIntervalEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.stories.ts b/frontend/src/components/fields/record-edit/TimeIntervalEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.stories.ts rename to frontend/src/components/fields/record-edit/TimeIntervalEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.vue b/frontend/src/components/fields/record-edit/TimeIntervalEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimeIntervalEdit.vue rename to frontend/src/components/fields/record-edit/TimeIntervalEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/TimezoneEdit.spec.ts b/frontend/src/components/fields/record-edit/TimezoneEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimezoneEdit.spec.ts rename to frontend/src/components/fields/record-edit/TimezoneEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimezoneEdit.stories.ts b/frontend/src/components/fields/record-edit/TimezoneEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimezoneEdit.stories.ts rename to frontend/src/components/fields/record-edit/TimezoneEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/TimezoneEdit.vue b/frontend/src/components/fields/record-edit/TimezoneEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/TimezoneEdit.vue rename to frontend/src/components/fields/record-edit/TimezoneEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/UrlEdit.spec.ts b/frontend/src/components/fields/record-edit/UrlEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UrlEdit.spec.ts rename to frontend/src/components/fields/record-edit/UrlEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/UrlEdit.stories.ts b/frontend/src/components/fields/record-edit/UrlEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UrlEdit.stories.ts rename to frontend/src/components/fields/record-edit/UrlEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/UrlEdit.vue b/frontend/src/components/fields/record-edit/UrlEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UrlEdit.vue rename to frontend/src/components/fields/record-edit/UrlEdit.vue diff --git a/frontend-vue/src/components/fields/record-edit/UuidEdit.spec.ts b/frontend/src/components/fields/record-edit/UuidEdit.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UuidEdit.spec.ts rename to frontend/src/components/fields/record-edit/UuidEdit.spec.ts diff --git a/frontend-vue/src/components/fields/record-edit/UuidEdit.stories.ts b/frontend/src/components/fields/record-edit/UuidEdit.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UuidEdit.stories.ts rename to frontend/src/components/fields/record-edit/UuidEdit.stories.ts diff --git a/frontend-vue/src/components/fields/record-edit/UuidEdit.vue b/frontend/src/components/fields/record-edit/UuidEdit.vue similarity index 100% rename from frontend-vue/src/components/fields/record-edit/UuidEdit.vue rename to frontend/src/components/fields/record-edit/UuidEdit.vue diff --git a/frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.spec.ts b/frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.spec.ts rename to frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.stories.ts b/frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.stories.ts rename to frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.vue b/frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryDataCaptionRecordView.vue rename to frontend/src/components/fields/record-view/BinaryDataCaptionRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/BinaryRecordView.spec.ts b/frontend/src/components/fields/record-view/BinaryRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryRecordView.spec.ts rename to frontend/src/components/fields/record-view/BinaryRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/BinaryRecordView.stories.ts b/frontend/src/components/fields/record-view/BinaryRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryRecordView.stories.ts rename to frontend/src/components/fields/record-view/BinaryRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/BinaryRecordView.vue b/frontend/src/components/fields/record-view/BinaryRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/BinaryRecordView.vue rename to frontend/src/components/fields/record-view/BinaryRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/BooleanRecordView.spec.ts b/frontend/src/components/fields/record-view/BooleanRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BooleanRecordView.spec.ts rename to frontend/src/components/fields/record-view/BooleanRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/BooleanRecordView.stories.ts b/frontend/src/components/fields/record-view/BooleanRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/BooleanRecordView.stories.ts rename to frontend/src/components/fields/record-view/BooleanRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/BooleanRecordView.vue b/frontend/src/components/fields/record-view/BooleanRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/BooleanRecordView.vue rename to frontend/src/components/fields/record-view/BooleanRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/CodeRecordView.spec.ts b/frontend/src/components/fields/record-view/CodeRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/CodeRecordView.spec.ts rename to frontend/src/components/fields/record-view/CodeRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/CodeRecordView.stories.ts b/frontend/src/components/fields/record-view/CodeRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/CodeRecordView.stories.ts rename to frontend/src/components/fields/record-view/CodeRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/CodeRecordView.vue b/frontend/src/components/fields/record-view/CodeRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/CodeRecordView.vue rename to frontend/src/components/fields/record-view/CodeRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/ColorRecordView.spec.ts b/frontend/src/components/fields/record-view/ColorRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ColorRecordView.spec.ts rename to frontend/src/components/fields/record-view/ColorRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/ColorRecordView.stories.ts b/frontend/src/components/fields/record-view/ColorRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ColorRecordView.stories.ts rename to frontend/src/components/fields/record-view/ColorRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/ColorRecordView.vue b/frontend/src/components/fields/record-view/ColorRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/ColorRecordView.vue rename to frontend/src/components/fields/record-view/ColorRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/CountryRecordView.spec.ts b/frontend/src/components/fields/record-view/CountryRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/CountryRecordView.spec.ts rename to frontend/src/components/fields/record-view/CountryRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/CountryRecordView.stories.ts b/frontend/src/components/fields/record-view/CountryRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/CountryRecordView.stories.ts rename to frontend/src/components/fields/record-view/CountryRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/CountryRecordView.vue b/frontend/src/components/fields/record-view/CountryRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/CountryRecordView.vue rename to frontend/src/components/fields/record-view/CountryRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/DateRecordView.spec.ts b/frontend/src/components/fields/record-view/DateRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateRecordView.spec.ts rename to frontend/src/components/fields/record-view/DateRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/DateRecordView.stories.ts b/frontend/src/components/fields/record-view/DateRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateRecordView.stories.ts rename to frontend/src/components/fields/record-view/DateRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/DateRecordView.vue b/frontend/src/components/fields/record-view/DateRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateRecordView.vue rename to frontend/src/components/fields/record-view/DateRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/DateTimeRecordView.spec.ts b/frontend/src/components/fields/record-view/DateTimeRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateTimeRecordView.spec.ts rename to frontend/src/components/fields/record-view/DateTimeRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/DateTimeRecordView.stories.ts b/frontend/src/components/fields/record-view/DateTimeRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateTimeRecordView.stories.ts rename to frontend/src/components/fields/record-view/DateTimeRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/DateTimeRecordView.vue b/frontend/src/components/fields/record-view/DateTimeRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/DateTimeRecordView.vue rename to frontend/src/components/fields/record-view/DateTimeRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/FileRecordView.spec.ts b/frontend/src/components/fields/record-view/FileRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/FileRecordView.spec.ts rename to frontend/src/components/fields/record-view/FileRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/FileRecordView.stories.ts b/frontend/src/components/fields/record-view/FileRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/FileRecordView.stories.ts rename to frontend/src/components/fields/record-view/FileRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/FileRecordView.vue b/frontend/src/components/fields/record-view/FileRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/FileRecordView.vue rename to frontend/src/components/fields/record-view/FileRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.spec.ts b/frontend/src/components/fields/record-view/ForeignKeyRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.spec.ts rename to frontend/src/components/fields/record-view/ForeignKeyRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.stories.ts b/frontend/src/components/fields/record-view/ForeignKeyRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.stories.ts rename to frontend/src/components/fields/record-view/ForeignKeyRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.vue b/frontend/src/components/fields/record-view/ForeignKeyRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/ForeignKeyRecordView.vue rename to frontend/src/components/fields/record-view/ForeignKeyRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/IdRecordView.spec.ts b/frontend/src/components/fields/record-view/IdRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/IdRecordView.spec.ts rename to frontend/src/components/fields/record-view/IdRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/IdRecordView.stories.ts b/frontend/src/components/fields/record-view/IdRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/IdRecordView.stories.ts rename to frontend/src/components/fields/record-view/IdRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/IdRecordView.vue b/frontend/src/components/fields/record-view/IdRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/IdRecordView.vue rename to frontend/src/components/fields/record-view/IdRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/ImageRecordView.spec.ts b/frontend/src/components/fields/record-view/ImageRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ImageRecordView.spec.ts rename to frontend/src/components/fields/record-view/ImageRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/ImageRecordView.stories.ts b/frontend/src/components/fields/record-view/ImageRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/ImageRecordView.stories.ts rename to frontend/src/components/fields/record-view/ImageRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/ImageRecordView.vue b/frontend/src/components/fields/record-view/ImageRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/ImageRecordView.vue rename to frontend/src/components/fields/record-view/ImageRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/JsonEditorRecordView.spec.ts b/frontend/src/components/fields/record-view/JsonEditorRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/JsonEditorRecordView.spec.ts rename to frontend/src/components/fields/record-view/JsonEditorRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/JsonEditorRecordView.stories.ts b/frontend/src/components/fields/record-view/JsonEditorRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/JsonEditorRecordView.stories.ts rename to frontend/src/components/fields/record-view/JsonEditorRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/JsonEditorRecordView.vue b/frontend/src/components/fields/record-view/JsonEditorRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/JsonEditorRecordView.vue rename to frontend/src/components/fields/record-view/JsonEditorRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/LanguageRecordView.spec.ts b/frontend/src/components/fields/record-view/LanguageRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/LanguageRecordView.spec.ts rename to frontend/src/components/fields/record-view/LanguageRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/LanguageRecordView.stories.ts b/frontend/src/components/fields/record-view/LanguageRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/LanguageRecordView.stories.ts rename to frontend/src/components/fields/record-view/LanguageRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/LanguageRecordView.vue b/frontend/src/components/fields/record-view/LanguageRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/LanguageRecordView.vue rename to frontend/src/components/fields/record-view/LanguageRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/LongTextRecordView.spec.ts b/frontend/src/components/fields/record-view/LongTextRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/LongTextRecordView.spec.ts rename to frontend/src/components/fields/record-view/LongTextRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/LongTextRecordView.stories.ts b/frontend/src/components/fields/record-view/LongTextRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/LongTextRecordView.stories.ts rename to frontend/src/components/fields/record-view/LongTextRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/LongTextRecordView.vue b/frontend/src/components/fields/record-view/LongTextRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/LongTextRecordView.vue rename to frontend/src/components/fields/record-view/LongTextRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/MarkdownRecordView.spec.ts b/frontend/src/components/fields/record-view/MarkdownRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/MarkdownRecordView.spec.ts rename to frontend/src/components/fields/record-view/MarkdownRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/MarkdownRecordView.stories.ts b/frontend/src/components/fields/record-view/MarkdownRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/MarkdownRecordView.stories.ts rename to frontend/src/components/fields/record-view/MarkdownRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/MarkdownRecordView.vue b/frontend/src/components/fields/record-view/MarkdownRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/MarkdownRecordView.vue rename to frontend/src/components/fields/record-view/MarkdownRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/MoneyRecordView.spec.ts b/frontend/src/components/fields/record-view/MoneyRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/MoneyRecordView.spec.ts rename to frontend/src/components/fields/record-view/MoneyRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/MoneyRecordView.stories.ts b/frontend/src/components/fields/record-view/MoneyRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/MoneyRecordView.stories.ts rename to frontend/src/components/fields/record-view/MoneyRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/MoneyRecordView.vue b/frontend/src/components/fields/record-view/MoneyRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/MoneyRecordView.vue rename to frontend/src/components/fields/record-view/MoneyRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/NumberRecordView.spec.ts b/frontend/src/components/fields/record-view/NumberRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/NumberRecordView.spec.ts rename to frontend/src/components/fields/record-view/NumberRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/NumberRecordView.stories.ts b/frontend/src/components/fields/record-view/NumberRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/NumberRecordView.stories.ts rename to frontend/src/components/fields/record-view/NumberRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/NumberRecordView.vue b/frontend/src/components/fields/record-view/NumberRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/NumberRecordView.vue rename to frontend/src/components/fields/record-view/NumberRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/PasswordRecordView.spec.ts b/frontend/src/components/fields/record-view/PasswordRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PasswordRecordView.spec.ts rename to frontend/src/components/fields/record-view/PasswordRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/PasswordRecordView.stories.ts b/frontend/src/components/fields/record-view/PasswordRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PasswordRecordView.stories.ts rename to frontend/src/components/fields/record-view/PasswordRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/PasswordRecordView.vue b/frontend/src/components/fields/record-view/PasswordRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/PasswordRecordView.vue rename to frontend/src/components/fields/record-view/PasswordRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/PhoneRecordView.spec.ts b/frontend/src/components/fields/record-view/PhoneRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PhoneRecordView.spec.ts rename to frontend/src/components/fields/record-view/PhoneRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/PhoneRecordView.stories.ts b/frontend/src/components/fields/record-view/PhoneRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PhoneRecordView.stories.ts rename to frontend/src/components/fields/record-view/PhoneRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/PhoneRecordView.vue b/frontend/src/components/fields/record-view/PhoneRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/PhoneRecordView.vue rename to frontend/src/components/fields/record-view/PhoneRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/PointRecordView.spec.ts b/frontend/src/components/fields/record-view/PointRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PointRecordView.spec.ts rename to frontend/src/components/fields/record-view/PointRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/PointRecordView.stories.ts b/frontend/src/components/fields/record-view/PointRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/PointRecordView.stories.ts rename to frontend/src/components/fields/record-view/PointRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/PointRecordView.vue b/frontend/src/components/fields/record-view/PointRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/PointRecordView.vue rename to frontend/src/components/fields/record-view/PointRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/RangeRecordView.spec.ts b/frontend/src/components/fields/record-view/RangeRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/RangeRecordView.spec.ts rename to frontend/src/components/fields/record-view/RangeRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/RangeRecordView.stories.ts b/frontend/src/components/fields/record-view/RangeRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/RangeRecordView.stories.ts rename to frontend/src/components/fields/record-view/RangeRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/RangeRecordView.vue b/frontend/src/components/fields/record-view/RangeRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/RangeRecordView.vue rename to frontend/src/components/fields/record-view/RangeRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/RecordViewValue.spec.ts b/frontend/src/components/fields/record-view/RecordViewValue.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/RecordViewValue.spec.ts rename to frontend/src/components/fields/record-view/RecordViewValue.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/RecordViewValue.vue b/frontend/src/components/fields/record-view/RecordViewValue.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/RecordViewValue.vue rename to frontend/src/components/fields/record-view/RecordViewValue.vue diff --git a/frontend-vue/src/components/fields/record-view/S3RecordView.spec.ts b/frontend/src/components/fields/record-view/S3RecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/S3RecordView.spec.ts rename to frontend/src/components/fields/record-view/S3RecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/S3RecordView.stories.ts b/frontend/src/components/fields/record-view/S3RecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/S3RecordView.stories.ts rename to frontend/src/components/fields/record-view/S3RecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/S3RecordView.vue b/frontend/src/components/fields/record-view/S3RecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/S3RecordView.vue rename to frontend/src/components/fields/record-view/S3RecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/SelectRecordView.spec.ts b/frontend/src/components/fields/record-view/SelectRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/SelectRecordView.spec.ts rename to frontend/src/components/fields/record-view/SelectRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/SelectRecordView.stories.ts b/frontend/src/components/fields/record-view/SelectRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/SelectRecordView.stories.ts rename to frontend/src/components/fields/record-view/SelectRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/SelectRecordView.vue b/frontend/src/components/fields/record-view/SelectRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/SelectRecordView.vue rename to frontend/src/components/fields/record-view/SelectRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/StaticTextRecordView.spec.ts b/frontend/src/components/fields/record-view/StaticTextRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/StaticTextRecordView.spec.ts rename to frontend/src/components/fields/record-view/StaticTextRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/StaticTextRecordView.stories.ts b/frontend/src/components/fields/record-view/StaticTextRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/StaticTextRecordView.stories.ts rename to frontend/src/components/fields/record-view/StaticTextRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/StaticTextRecordView.vue b/frontend/src/components/fields/record-view/StaticTextRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/StaticTextRecordView.vue rename to frontend/src/components/fields/record-view/StaticTextRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/TextRecordView.spec.ts b/frontend/src/components/fields/record-view/TextRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TextRecordView.spec.ts rename to frontend/src/components/fields/record-view/TextRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/TextRecordView.stories.ts b/frontend/src/components/fields/record-view/TextRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TextRecordView.stories.ts rename to frontend/src/components/fields/record-view/TextRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/TextRecordView.vue b/frontend/src/components/fields/record-view/TextRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/TextRecordView.vue rename to frontend/src/components/fields/record-view/TextRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.spec.ts b/frontend/src/components/fields/record-view/TimeIntervalRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.spec.ts rename to frontend/src/components/fields/record-view/TimeIntervalRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.stories.ts b/frontend/src/components/fields/record-view/TimeIntervalRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.stories.ts rename to frontend/src/components/fields/record-view/TimeIntervalRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.vue b/frontend/src/components/fields/record-view/TimeIntervalRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeIntervalRecordView.vue rename to frontend/src/components/fields/record-view/TimeIntervalRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/TimeRecordView.spec.ts b/frontend/src/components/fields/record-view/TimeRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeRecordView.spec.ts rename to frontend/src/components/fields/record-view/TimeRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/TimeRecordView.stories.ts b/frontend/src/components/fields/record-view/TimeRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeRecordView.stories.ts rename to frontend/src/components/fields/record-view/TimeRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/TimeRecordView.vue b/frontend/src/components/fields/record-view/TimeRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimeRecordView.vue rename to frontend/src/components/fields/record-view/TimeRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/TimezoneRecordView.spec.ts b/frontend/src/components/fields/record-view/TimezoneRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimezoneRecordView.spec.ts rename to frontend/src/components/fields/record-view/TimezoneRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/TimezoneRecordView.stories.ts b/frontend/src/components/fields/record-view/TimezoneRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimezoneRecordView.stories.ts rename to frontend/src/components/fields/record-view/TimezoneRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/TimezoneRecordView.vue b/frontend/src/components/fields/record-view/TimezoneRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/TimezoneRecordView.vue rename to frontend/src/components/fields/record-view/TimezoneRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/UrlRecordView.spec.ts b/frontend/src/components/fields/record-view/UrlRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/UrlRecordView.spec.ts rename to frontend/src/components/fields/record-view/UrlRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/UrlRecordView.stories.ts b/frontend/src/components/fields/record-view/UrlRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/UrlRecordView.stories.ts rename to frontend/src/components/fields/record-view/UrlRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/UrlRecordView.vue b/frontend/src/components/fields/record-view/UrlRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/UrlRecordView.vue rename to frontend/src/components/fields/record-view/UrlRecordView.vue diff --git a/frontend-vue/src/components/fields/record-view/UuidRecordView.spec.ts b/frontend/src/components/fields/record-view/UuidRecordView.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/UuidRecordView.spec.ts rename to frontend/src/components/fields/record-view/UuidRecordView.spec.ts diff --git a/frontend-vue/src/components/fields/record-view/UuidRecordView.stories.ts b/frontend/src/components/fields/record-view/UuidRecordView.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/record-view/UuidRecordView.stories.ts rename to frontend/src/components/fields/record-view/UuidRecordView.stories.ts diff --git a/frontend-vue/src/components/fields/record-view/UuidRecordView.vue b/frontend/src/components/fields/record-view/UuidRecordView.vue similarity index 100% rename from frontend-vue/src/components/fields/record-view/UuidRecordView.vue rename to frontend/src/components/fields/record-view/UuidRecordView.vue diff --git a/frontend-vue/src/components/fields/stories.mocks.ts b/frontend/src/components/fields/stories.mocks.ts similarity index 100% rename from frontend-vue/src/components/fields/stories.mocks.ts rename to frontend/src/components/fields/stories.mocks.ts diff --git a/frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.spec.ts b/frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.spec.ts rename to frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.stories.ts b/frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.stories.ts rename to frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.vue b/frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDataCaptionDisplay.vue rename to frontend/src/components/fields/table-display/BinaryDataCaptionDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/BinaryDisplay.spec.ts b/frontend/src/components/fields/table-display/BinaryDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDisplay.spec.ts rename to frontend/src/components/fields/table-display/BinaryDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/BinaryDisplay.stories.ts b/frontend/src/components/fields/table-display/BinaryDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDisplay.stories.ts rename to frontend/src/components/fields/table-display/BinaryDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/BinaryDisplay.vue b/frontend/src/components/fields/table-display/BinaryDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/BinaryDisplay.vue rename to frontend/src/components/fields/table-display/BinaryDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/BooleanDisplay.spec.ts b/frontend/src/components/fields/table-display/BooleanDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BooleanDisplay.spec.ts rename to frontend/src/components/fields/table-display/BooleanDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/BooleanDisplay.stories.ts b/frontend/src/components/fields/table-display/BooleanDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/BooleanDisplay.stories.ts rename to frontend/src/components/fields/table-display/BooleanDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/BooleanDisplay.vue b/frontend/src/components/fields/table-display/BooleanDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/BooleanDisplay.vue rename to frontend/src/components/fields/table-display/BooleanDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/CodeDisplay.spec.ts b/frontend/src/components/fields/table-display/CodeDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/CodeDisplay.spec.ts rename to frontend/src/components/fields/table-display/CodeDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/CodeDisplay.stories.ts b/frontend/src/components/fields/table-display/CodeDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/CodeDisplay.stories.ts rename to frontend/src/components/fields/table-display/CodeDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/CodeDisplay.vue b/frontend/src/components/fields/table-display/CodeDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/CodeDisplay.vue rename to frontend/src/components/fields/table-display/CodeDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/ColorDisplay.spec.ts b/frontend/src/components/fields/table-display/ColorDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ColorDisplay.spec.ts rename to frontend/src/components/fields/table-display/ColorDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/ColorDisplay.stories.ts b/frontend/src/components/fields/table-display/ColorDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ColorDisplay.stories.ts rename to frontend/src/components/fields/table-display/ColorDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/ColorDisplay.vue b/frontend/src/components/fields/table-display/ColorDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/ColorDisplay.vue rename to frontend/src/components/fields/table-display/ColorDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/CountryDisplay.spec.ts b/frontend/src/components/fields/table-display/CountryDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/CountryDisplay.spec.ts rename to frontend/src/components/fields/table-display/CountryDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/CountryDisplay.stories.ts b/frontend/src/components/fields/table-display/CountryDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/CountryDisplay.stories.ts rename to frontend/src/components/fields/table-display/CountryDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/CountryDisplay.vue b/frontend/src/components/fields/table-display/CountryDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/CountryDisplay.vue rename to frontend/src/components/fields/table-display/CountryDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/DateDisplay.spec.ts b/frontend/src/components/fields/table-display/DateDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateDisplay.spec.ts rename to frontend/src/components/fields/table-display/DateDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/DateDisplay.stories.ts b/frontend/src/components/fields/table-display/DateDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateDisplay.stories.ts rename to frontend/src/components/fields/table-display/DateDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/DateDisplay.vue b/frontend/src/components/fields/table-display/DateDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateDisplay.vue rename to frontend/src/components/fields/table-display/DateDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/DateTimeDisplay.spec.ts b/frontend/src/components/fields/table-display/DateTimeDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateTimeDisplay.spec.ts rename to frontend/src/components/fields/table-display/DateTimeDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/DateTimeDisplay.stories.ts b/frontend/src/components/fields/table-display/DateTimeDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateTimeDisplay.stories.ts rename to frontend/src/components/fields/table-display/DateTimeDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/DateTimeDisplay.vue b/frontend/src/components/fields/table-display/DateTimeDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/DateTimeDisplay.vue rename to frontend/src/components/fields/table-display/DateTimeDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/FileDisplay.spec.ts b/frontend/src/components/fields/table-display/FileDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/FileDisplay.spec.ts rename to frontend/src/components/fields/table-display/FileDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/FileDisplay.stories.ts b/frontend/src/components/fields/table-display/FileDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/FileDisplay.stories.ts rename to frontend/src/components/fields/table-display/FileDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/FileDisplay.vue b/frontend/src/components/fields/table-display/FileDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/FileDisplay.vue rename to frontend/src/components/fields/table-display/FileDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.spec.ts b/frontend/src/components/fields/table-display/ForeignKeyDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.spec.ts rename to frontend/src/components/fields/table-display/ForeignKeyDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.stories.ts b/frontend/src/components/fields/table-display/ForeignKeyDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.stories.ts rename to frontend/src/components/fields/table-display/ForeignKeyDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.vue b/frontend/src/components/fields/table-display/ForeignKeyDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/ForeignKeyDisplay.vue rename to frontend/src/components/fields/table-display/ForeignKeyDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/IdDisplay.spec.ts b/frontend/src/components/fields/table-display/IdDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/IdDisplay.spec.ts rename to frontend/src/components/fields/table-display/IdDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/IdDisplay.stories.ts b/frontend/src/components/fields/table-display/IdDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/IdDisplay.stories.ts rename to frontend/src/components/fields/table-display/IdDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/IdDisplay.vue b/frontend/src/components/fields/table-display/IdDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/IdDisplay.vue rename to frontend/src/components/fields/table-display/IdDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/ImageDisplay.spec.ts b/frontend/src/components/fields/table-display/ImageDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ImageDisplay.spec.ts rename to frontend/src/components/fields/table-display/ImageDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/ImageDisplay.stories.ts b/frontend/src/components/fields/table-display/ImageDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/ImageDisplay.stories.ts rename to frontend/src/components/fields/table-display/ImageDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/ImageDisplay.vue b/frontend/src/components/fields/table-display/ImageDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/ImageDisplay.vue rename to frontend/src/components/fields/table-display/ImageDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/JsonEditorDisplay.spec.ts b/frontend/src/components/fields/table-display/JsonEditorDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/JsonEditorDisplay.spec.ts rename to frontend/src/components/fields/table-display/JsonEditorDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/JsonEditorDisplay.stories.ts b/frontend/src/components/fields/table-display/JsonEditorDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/JsonEditorDisplay.stories.ts rename to frontend/src/components/fields/table-display/JsonEditorDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/JsonEditorDisplay.vue b/frontend/src/components/fields/table-display/JsonEditorDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/JsonEditorDisplay.vue rename to frontend/src/components/fields/table-display/JsonEditorDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/LanguageDisplay.spec.ts b/frontend/src/components/fields/table-display/LanguageDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/LanguageDisplay.spec.ts rename to frontend/src/components/fields/table-display/LanguageDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/LanguageDisplay.stories.ts b/frontend/src/components/fields/table-display/LanguageDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/LanguageDisplay.stories.ts rename to frontend/src/components/fields/table-display/LanguageDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/LanguageDisplay.vue b/frontend/src/components/fields/table-display/LanguageDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/LanguageDisplay.vue rename to frontend/src/components/fields/table-display/LanguageDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/LongTextDisplay.spec.ts b/frontend/src/components/fields/table-display/LongTextDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/LongTextDisplay.spec.ts rename to frontend/src/components/fields/table-display/LongTextDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/LongTextDisplay.stories.ts b/frontend/src/components/fields/table-display/LongTextDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/LongTextDisplay.stories.ts rename to frontend/src/components/fields/table-display/LongTextDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/LongTextDisplay.vue b/frontend/src/components/fields/table-display/LongTextDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/LongTextDisplay.vue rename to frontend/src/components/fields/table-display/LongTextDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/MarkdownDisplay.spec.ts b/frontend/src/components/fields/table-display/MarkdownDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/MarkdownDisplay.spec.ts rename to frontend/src/components/fields/table-display/MarkdownDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/MarkdownDisplay.stories.ts b/frontend/src/components/fields/table-display/MarkdownDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/MarkdownDisplay.stories.ts rename to frontend/src/components/fields/table-display/MarkdownDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/MarkdownDisplay.vue b/frontend/src/components/fields/table-display/MarkdownDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/MarkdownDisplay.vue rename to frontend/src/components/fields/table-display/MarkdownDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/MoneyDisplay.spec.ts b/frontend/src/components/fields/table-display/MoneyDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/MoneyDisplay.spec.ts rename to frontend/src/components/fields/table-display/MoneyDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/MoneyDisplay.stories.ts b/frontend/src/components/fields/table-display/MoneyDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/MoneyDisplay.stories.ts rename to frontend/src/components/fields/table-display/MoneyDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/MoneyDisplay.vue b/frontend/src/components/fields/table-display/MoneyDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/MoneyDisplay.vue rename to frontend/src/components/fields/table-display/MoneyDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/NumberDisplay.spec.ts b/frontend/src/components/fields/table-display/NumberDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/NumberDisplay.spec.ts rename to frontend/src/components/fields/table-display/NumberDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/NumberDisplay.stories.ts b/frontend/src/components/fields/table-display/NumberDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/NumberDisplay.stories.ts rename to frontend/src/components/fields/table-display/NumberDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/NumberDisplay.vue b/frontend/src/components/fields/table-display/NumberDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/NumberDisplay.vue rename to frontend/src/components/fields/table-display/NumberDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/PasswordDisplay.spec.ts b/frontend/src/components/fields/table-display/PasswordDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PasswordDisplay.spec.ts rename to frontend/src/components/fields/table-display/PasswordDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/PasswordDisplay.stories.ts b/frontend/src/components/fields/table-display/PasswordDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PasswordDisplay.stories.ts rename to frontend/src/components/fields/table-display/PasswordDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/PasswordDisplay.vue b/frontend/src/components/fields/table-display/PasswordDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/PasswordDisplay.vue rename to frontend/src/components/fields/table-display/PasswordDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/PhoneDisplay.spec.ts b/frontend/src/components/fields/table-display/PhoneDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PhoneDisplay.spec.ts rename to frontend/src/components/fields/table-display/PhoneDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/PhoneDisplay.stories.ts b/frontend/src/components/fields/table-display/PhoneDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PhoneDisplay.stories.ts rename to frontend/src/components/fields/table-display/PhoneDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/PhoneDisplay.vue b/frontend/src/components/fields/table-display/PhoneDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/PhoneDisplay.vue rename to frontend/src/components/fields/table-display/PhoneDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/PointDisplay.spec.ts b/frontend/src/components/fields/table-display/PointDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PointDisplay.spec.ts rename to frontend/src/components/fields/table-display/PointDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/PointDisplay.stories.ts b/frontend/src/components/fields/table-display/PointDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/PointDisplay.stories.ts rename to frontend/src/components/fields/table-display/PointDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/PointDisplay.vue b/frontend/src/components/fields/table-display/PointDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/PointDisplay.vue rename to frontend/src/components/fields/table-display/PointDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/RangeDisplay.spec.ts b/frontend/src/components/fields/table-display/RangeDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/RangeDisplay.spec.ts rename to frontend/src/components/fields/table-display/RangeDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/RangeDisplay.stories.ts b/frontend/src/components/fields/table-display/RangeDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/RangeDisplay.stories.ts rename to frontend/src/components/fields/table-display/RangeDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/RangeDisplay.vue b/frontend/src/components/fields/table-display/RangeDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/RangeDisplay.vue rename to frontend/src/components/fields/table-display/RangeDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/S3Display.spec.ts b/frontend/src/components/fields/table-display/S3Display.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/S3Display.spec.ts rename to frontend/src/components/fields/table-display/S3Display.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/S3Display.stories.ts b/frontend/src/components/fields/table-display/S3Display.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/S3Display.stories.ts rename to frontend/src/components/fields/table-display/S3Display.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/S3Display.vue b/frontend/src/components/fields/table-display/S3Display.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/S3Display.vue rename to frontend/src/components/fields/table-display/S3Display.vue diff --git a/frontend-vue/src/components/fields/table-display/SelectDisplay.spec.ts b/frontend/src/components/fields/table-display/SelectDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/SelectDisplay.spec.ts rename to frontend/src/components/fields/table-display/SelectDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/SelectDisplay.stories.ts b/frontend/src/components/fields/table-display/SelectDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/SelectDisplay.stories.ts rename to frontend/src/components/fields/table-display/SelectDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/SelectDisplay.vue b/frontend/src/components/fields/table-display/SelectDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/SelectDisplay.vue rename to frontend/src/components/fields/table-display/SelectDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/StaticTextDisplay.spec.ts b/frontend/src/components/fields/table-display/StaticTextDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/StaticTextDisplay.spec.ts rename to frontend/src/components/fields/table-display/StaticTextDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/StaticTextDisplay.stories.ts b/frontend/src/components/fields/table-display/StaticTextDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/StaticTextDisplay.stories.ts rename to frontend/src/components/fields/table-display/StaticTextDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/StaticTextDisplay.vue b/frontend/src/components/fields/table-display/StaticTextDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/StaticTextDisplay.vue rename to frontend/src/components/fields/table-display/StaticTextDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/TableDisplayCell.spec.ts b/frontend/src/components/fields/table-display/TableDisplayCell.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TableDisplayCell.spec.ts rename to frontend/src/components/fields/table-display/TableDisplayCell.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/TableDisplayCell.vue b/frontend/src/components/fields/table-display/TableDisplayCell.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/TableDisplayCell.vue rename to frontend/src/components/fields/table-display/TableDisplayCell.vue diff --git a/frontend-vue/src/components/fields/table-display/TextDisplay.spec.ts b/frontend/src/components/fields/table-display/TextDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TextDisplay.spec.ts rename to frontend/src/components/fields/table-display/TextDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/TextDisplay.stories.ts b/frontend/src/components/fields/table-display/TextDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TextDisplay.stories.ts rename to frontend/src/components/fields/table-display/TextDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/TextDisplay.vue b/frontend/src/components/fields/table-display/TextDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/TextDisplay.vue rename to frontend/src/components/fields/table-display/TextDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/TimeDisplay.spec.ts b/frontend/src/components/fields/table-display/TimeDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeDisplay.spec.ts rename to frontend/src/components/fields/table-display/TimeDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/TimeDisplay.stories.ts b/frontend/src/components/fields/table-display/TimeDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeDisplay.stories.ts rename to frontend/src/components/fields/table-display/TimeDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/TimeDisplay.vue b/frontend/src/components/fields/table-display/TimeDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeDisplay.vue rename to frontend/src/components/fields/table-display/TimeDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.spec.ts b/frontend/src/components/fields/table-display/TimeIntervalDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.spec.ts rename to frontend/src/components/fields/table-display/TimeIntervalDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.stories.ts b/frontend/src/components/fields/table-display/TimeIntervalDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.stories.ts rename to frontend/src/components/fields/table-display/TimeIntervalDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.vue b/frontend/src/components/fields/table-display/TimeIntervalDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimeIntervalDisplay.vue rename to frontend/src/components/fields/table-display/TimeIntervalDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/TimezoneDisplay.spec.ts b/frontend/src/components/fields/table-display/TimezoneDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimezoneDisplay.spec.ts rename to frontend/src/components/fields/table-display/TimezoneDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/TimezoneDisplay.stories.ts b/frontend/src/components/fields/table-display/TimezoneDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimezoneDisplay.stories.ts rename to frontend/src/components/fields/table-display/TimezoneDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/TimezoneDisplay.vue b/frontend/src/components/fields/table-display/TimezoneDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/TimezoneDisplay.vue rename to frontend/src/components/fields/table-display/TimezoneDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/UrlDisplay.spec.ts b/frontend/src/components/fields/table-display/UrlDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/UrlDisplay.spec.ts rename to frontend/src/components/fields/table-display/UrlDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/UrlDisplay.stories.ts b/frontend/src/components/fields/table-display/UrlDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/UrlDisplay.stories.ts rename to frontend/src/components/fields/table-display/UrlDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/UrlDisplay.vue b/frontend/src/components/fields/table-display/UrlDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/UrlDisplay.vue rename to frontend/src/components/fields/table-display/UrlDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/UuidDisplay.spec.ts b/frontend/src/components/fields/table-display/UuidDisplay.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/UuidDisplay.spec.ts rename to frontend/src/components/fields/table-display/UuidDisplay.spec.ts diff --git a/frontend-vue/src/components/fields/table-display/UuidDisplay.stories.ts b/frontend/src/components/fields/table-display/UuidDisplay.stories.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/UuidDisplay.stories.ts rename to frontend/src/components/fields/table-display/UuidDisplay.stories.ts diff --git a/frontend-vue/src/components/fields/table-display/UuidDisplay.vue b/frontend/src/components/fields/table-display/UuidDisplay.vue similarity index 100% rename from frontend-vue/src/components/fields/table-display/UuidDisplay.vue rename to frontend/src/components/fields/table-display/UuidDisplay.vue diff --git a/frontend-vue/src/components/fields/table-display/testing.ts b/frontend/src/components/fields/table-display/testing.ts similarity index 100% rename from frontend-vue/src/components/fields/table-display/testing.ts rename to frontend/src/components/fields/table-display/testing.ts diff --git a/frontend-vue/src/components/fields/types.ts b/frontend/src/components/fields/types.ts similarity index 100% rename from frontend-vue/src/components/fields/types.ts rename to frontend/src/components/fields/types.ts diff --git a/frontend-vue/src/components/fields/useFieldWidget.spec.ts b/frontend/src/components/fields/useFieldWidget.spec.ts similarity index 100% rename from frontend-vue/src/components/fields/useFieldWidget.spec.ts rename to frontend/src/components/fields/useFieldWidget.spec.ts diff --git a/frontend-vue/src/components/fields/useFieldWidget.ts b/frontend/src/components/fields/useFieldWidget.ts similarity index 100% rename from frontend-vue/src/components/fields/useFieldWidget.ts rename to frontend/src/components/fields/useFieldWidget.ts diff --git a/frontend-vue/src/components/master-password/MasterPasswordDialog.spec.ts b/frontend/src/components/master-password/MasterPasswordDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/master-password/MasterPasswordDialog.spec.ts rename to frontend/src/components/master-password/MasterPasswordDialog.spec.ts diff --git a/frontend-vue/src/components/master-password/MasterPasswordDialog.vue b/frontend/src/components/master-password/MasterPasswordDialog.vue similarity index 100% rename from frontend-vue/src/components/master-password/MasterPasswordDialog.vue rename to frontend/src/components/master-password/MasterPasswordDialog.vue diff --git a/frontend-vue/src/components/profile/ProfileLayout.vue b/frontend/src/components/profile/ProfileLayout.vue similarity index 100% rename from frontend-vue/src/components/profile/ProfileLayout.vue rename to frontend/src/components/profile/ProfileLayout.vue diff --git a/frontend-vue/src/components/profile/ProfileSidebar.spec.ts b/frontend/src/components/profile/ProfileSidebar.spec.ts similarity index 100% rename from frontend-vue/src/components/profile/ProfileSidebar.spec.ts rename to frontend/src/components/profile/ProfileSidebar.spec.ts diff --git a/frontend-vue/src/components/profile/ProfileSidebar.vue b/frontend/src/components/profile/ProfileSidebar.vue similarity index 100% rename from frontend-vue/src/components/profile/ProfileSidebar.vue rename to frontend/src/components/profile/ProfileSidebar.vue diff --git a/frontend-vue/src/components/schema/SchemaDiagramViewer.spec.ts b/frontend/src/components/schema/SchemaDiagramViewer.spec.ts similarity index 100% rename from frontend-vue/src/components/schema/SchemaDiagramViewer.spec.ts rename to frontend/src/components/schema/SchemaDiagramViewer.spec.ts diff --git a/frontend-vue/src/components/schema/SchemaDiagramViewer.vue b/frontend/src/components/schema/SchemaDiagramViewer.vue similarity index 100% rename from frontend-vue/src/components/schema/SchemaDiagramViewer.vue rename to frontend/src/components/schema/SchemaDiagramViewer.vue diff --git a/frontend-vue/src/components/schema/SchemaEditor.spec.ts b/frontend/src/components/schema/SchemaEditor.spec.ts similarity index 100% rename from frontend-vue/src/components/schema/SchemaEditor.spec.ts rename to frontend/src/components/schema/SchemaEditor.spec.ts diff --git a/frontend-vue/src/components/schema/SchemaEditor.vue b/frontend/src/components/schema/SchemaEditor.vue similarity index 100% rename from frontend-vue/src/components/schema/SchemaEditor.vue rename to frontend/src/components/schema/SchemaEditor.vue diff --git a/frontend-vue/src/components/schema/mermaid.ts b/frontend/src/components/schema/mermaid.ts similarity index 100% rename from frontend-vue/src/components/schema/mermaid.ts rename to frontend/src/components/schema/mermaid.ts diff --git a/frontend-vue/src/components/schema/schemaChanges.spec.ts b/frontend/src/components/schema/schemaChanges.spec.ts similarity index 100% rename from frontend-vue/src/components/schema/schemaChanges.spec.ts rename to frontend/src/components/schema/schemaChanges.spec.ts diff --git a/frontend-vue/src/components/schema/schemaChanges.ts b/frontend/src/components/schema/schemaChanges.ts similarity index 100% rename from frontend-vue/src/components/schema/schemaChanges.ts rename to frontend/src/components/schema/schemaChanges.ts diff --git a/frontend-vue/src/components/secrets/CreateSecretDialog.spec.ts b/frontend/src/components/secrets/CreateSecretDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/CreateSecretDialog.spec.ts rename to frontend/src/components/secrets/CreateSecretDialog.spec.ts diff --git a/frontend-vue/src/components/secrets/CreateSecretDialog.vue b/frontend/src/components/secrets/CreateSecretDialog.vue similarity index 100% rename from frontend-vue/src/components/secrets/CreateSecretDialog.vue rename to frontend/src/components/secrets/CreateSecretDialog.vue diff --git a/frontend-vue/src/components/secrets/DeleteSecretDialog.spec.ts b/frontend/src/components/secrets/DeleteSecretDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/DeleteSecretDialog.spec.ts rename to frontend/src/components/secrets/DeleteSecretDialog.spec.ts diff --git a/frontend-vue/src/components/secrets/DeleteSecretDialog.vue b/frontend/src/components/secrets/DeleteSecretDialog.vue similarity index 100% rename from frontend-vue/src/components/secrets/DeleteSecretDialog.vue rename to frontend/src/components/secrets/DeleteSecretDialog.vue diff --git a/frontend-vue/src/components/secrets/EditSecretDialog.spec.ts b/frontend/src/components/secrets/EditSecretDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/EditSecretDialog.spec.ts rename to frontend/src/components/secrets/EditSecretDialog.spec.ts diff --git a/frontend-vue/src/components/secrets/EditSecretDialog.vue b/frontend/src/components/secrets/EditSecretDialog.vue similarity index 100% rename from frontend-vue/src/components/secrets/EditSecretDialog.vue rename to frontend/src/components/secrets/EditSecretDialog.vue diff --git a/frontend-vue/src/components/secrets/SecretAuditLogDialog.spec.ts b/frontend/src/components/secrets/SecretAuditLogDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/SecretAuditLogDialog.spec.ts rename to frontend/src/components/secrets/SecretAuditLogDialog.spec.ts diff --git a/frontend-vue/src/components/secrets/SecretAuditLogDialog.vue b/frontend/src/components/secrets/SecretAuditLogDialog.vue similarity index 100% rename from frontend-vue/src/components/secrets/SecretAuditLogDialog.vue rename to frontend/src/components/secrets/SecretAuditLogDialog.vue diff --git a/frontend-vue/src/components/secrets/SecretMasterPasswordDialog.spec.ts b/frontend/src/components/secrets/SecretMasterPasswordDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/SecretMasterPasswordDialog.spec.ts rename to frontend/src/components/secrets/SecretMasterPasswordDialog.spec.ts diff --git a/frontend-vue/src/components/secrets/SecretMasterPasswordDialog.vue b/frontend/src/components/secrets/SecretMasterPasswordDialog.vue similarity index 100% rename from frontend-vue/src/components/secrets/SecretMasterPasswordDialog.vue rename to frontend/src/components/secrets/SecretMasterPasswordDialog.vue diff --git a/frontend-vue/src/components/secrets/secretUtils.spec.ts b/frontend/src/components/secrets/secretUtils.spec.ts similarity index 100% rename from frontend-vue/src/components/secrets/secretUtils.spec.ts rename to frontend/src/components/secrets/secretUtils.spec.ts diff --git a/frontend-vue/src/components/secrets/secretUtils.ts b/frontend/src/components/secrets/secretUtils.ts similarity index 100% rename from frontend-vue/src/components/secrets/secretUtils.ts rename to frontend/src/components/secrets/secretUtils.ts diff --git a/frontend-vue/src/components/secrets/secretsTestUtils.ts b/frontend/src/components/secrets/secretsTestUtils.ts similarity index 100% rename from frontend-vue/src/components/secrets/secretsTestUtils.ts rename to frontend/src/components/secrets/secretsTestUtils.ts diff --git a/frontend-vue/src/components/shared/AppAlert.spec.ts b/frontend/src/components/shared/AppAlert.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppAlert.spec.ts rename to frontend/src/components/shared/AppAlert.spec.ts diff --git a/frontend-vue/src/components/shared/AppAlert.stories.ts b/frontend/src/components/shared/AppAlert.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppAlert.stories.ts rename to frontend/src/components/shared/AppAlert.stories.ts diff --git a/frontend-vue/src/components/shared/AppAlert.vue b/frontend/src/components/shared/AppAlert.vue similarity index 100% rename from frontend-vue/src/components/shared/AppAlert.vue rename to frontend/src/components/shared/AppAlert.vue diff --git a/frontend-vue/src/components/shared/AppBanner.spec.ts b/frontend/src/components/shared/AppBanner.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppBanner.spec.ts rename to frontend/src/components/shared/AppBanner.spec.ts diff --git a/frontend-vue/src/components/shared/AppBanner.stories.ts b/frontend/src/components/shared/AppBanner.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppBanner.stories.ts rename to frontend/src/components/shared/AppBanner.stories.ts diff --git a/frontend-vue/src/components/shared/AppBanner.vue b/frontend/src/components/shared/AppBanner.vue similarity index 100% rename from frontend-vue/src/components/shared/AppBanner.vue rename to frontend/src/components/shared/AppBanner.vue diff --git a/frontend-vue/src/components/shared/AppBreadcrumbs.spec.ts b/frontend/src/components/shared/AppBreadcrumbs.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppBreadcrumbs.spec.ts rename to frontend/src/components/shared/AppBreadcrumbs.spec.ts diff --git a/frontend-vue/src/components/shared/AppBreadcrumbs.stories.ts b/frontend/src/components/shared/AppBreadcrumbs.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppBreadcrumbs.stories.ts rename to frontend/src/components/shared/AppBreadcrumbs.stories.ts diff --git a/frontend-vue/src/components/shared/AppBreadcrumbs.vue b/frontend/src/components/shared/AppBreadcrumbs.vue similarity index 100% rename from frontend-vue/src/components/shared/AppBreadcrumbs.vue rename to frontend/src/components/shared/AppBreadcrumbs.vue diff --git a/frontend-vue/src/components/shared/AppCodeEditor.spec.ts b/frontend/src/components/shared/AppCodeEditor.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppCodeEditor.spec.ts rename to frontend/src/components/shared/AppCodeEditor.spec.ts diff --git a/frontend-vue/src/components/shared/AppCodeEditor.stories.ts b/frontend/src/components/shared/AppCodeEditor.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppCodeEditor.stories.ts rename to frontend/src/components/shared/AppCodeEditor.stories.ts diff --git a/frontend-vue/src/components/shared/AppCodeEditor.vue b/frontend/src/components/shared/AppCodeEditor.vue similarity index 100% rename from frontend-vue/src/components/shared/AppCodeEditor.vue rename to frontend/src/components/shared/AppCodeEditor.vue diff --git a/frontend-vue/src/components/shared/AppContentLoader.spec.ts b/frontend/src/components/shared/AppContentLoader.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppContentLoader.spec.ts rename to frontend/src/components/shared/AppContentLoader.spec.ts diff --git a/frontend-vue/src/components/shared/AppContentLoader.stories.ts b/frontend/src/components/shared/AppContentLoader.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppContentLoader.stories.ts rename to frontend/src/components/shared/AppContentLoader.stories.ts diff --git a/frontend-vue/src/components/shared/AppContentLoader.vue b/frontend/src/components/shared/AppContentLoader.vue similarity index 100% rename from frontend-vue/src/components/shared/AppContentLoader.vue rename to frontend/src/components/shared/AppContentLoader.vue diff --git a/frontend-vue/src/components/shared/AppIconPicker.spec.ts b/frontend/src/components/shared/AppIconPicker.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppIconPicker.spec.ts rename to frontend/src/components/shared/AppIconPicker.spec.ts diff --git a/frontend-vue/src/components/shared/AppIconPicker.stories.ts b/frontend/src/components/shared/AppIconPicker.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppIconPicker.stories.ts rename to frontend/src/components/shared/AppIconPicker.stories.ts diff --git a/frontend-vue/src/components/shared/AppIconPicker.vue b/frontend/src/components/shared/AppIconPicker.vue similarity index 100% rename from frontend-vue/src/components/shared/AppIconPicker.vue rename to frontend/src/components/shared/AppIconPicker.vue diff --git a/frontend-vue/src/components/shared/AppIpAddressButton.spec.ts b/frontend/src/components/shared/AppIpAddressButton.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppIpAddressButton.spec.ts rename to frontend/src/components/shared/AppIpAddressButton.spec.ts diff --git a/frontend-vue/src/components/shared/AppIpAddressButton.vue b/frontend/src/components/shared/AppIpAddressButton.vue similarity index 100% rename from frontend-vue/src/components/shared/AppIpAddressButton.vue rename to frontend/src/components/shared/AppIpAddressButton.vue diff --git a/frontend-vue/src/components/shared/AppMarkdown.spec.ts b/frontend/src/components/shared/AppMarkdown.spec.ts similarity index 100% rename from frontend-vue/src/components/shared/AppMarkdown.spec.ts rename to frontend/src/components/shared/AppMarkdown.spec.ts diff --git a/frontend-vue/src/components/shared/AppMarkdown.stories.ts b/frontend/src/components/shared/AppMarkdown.stories.ts similarity index 100% rename from frontend-vue/src/components/shared/AppMarkdown.stories.ts rename to frontend/src/components/shared/AppMarkdown.stories.ts diff --git a/frontend-vue/src/components/shared/AppMarkdown.vue b/frontend/src/components/shared/AppMarkdown.vue similarity index 100% rename from frontend-vue/src/components/shared/AppMarkdown.vue rename to frontend/src/components/shared/AppMarkdown.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderAddUserDialog.vue b/frontend/src/components/skeletons/PlaceholderAddUserDialog.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderAddUserDialog.vue rename to frontend/src/components/skeletons/PlaceholderAddUserDialog.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderApiKeysList.vue b/frontend/src/components/skeletons/PlaceholderApiKeysList.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderApiKeysList.vue rename to frontend/src/components/skeletons/PlaceholderApiKeysList.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderAuditLog.vue b/frontend/src/components/skeletons/PlaceholderAuditLog.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderAuditLog.vue rename to frontend/src/components/skeletons/PlaceholderAuditLog.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderCompany.vue b/frontend/src/components/skeletons/PlaceholderCompany.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderCompany.vue rename to frontend/src/components/skeletons/PlaceholderCompany.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderCompanyInvitation.vue b/frontend/src/components/skeletons/PlaceholderCompanyInvitation.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderCompanyInvitation.vue rename to frontend/src/components/skeletons/PlaceholderCompanyInvitation.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderConnectionSettings.vue b/frontend/src/components/skeletons/PlaceholderConnectionSettings.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderConnectionSettings.vue rename to frontend/src/components/skeletons/PlaceholderConnectionSettings.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderConnections.vue b/frontend/src/components/skeletons/PlaceholderConnections.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderConnections.vue rename to frontend/src/components/skeletons/PlaceholderConnections.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderDashboards.vue b/frontend/src/components/skeletons/PlaceholderDashboards.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderDashboards.vue rename to frontend/src/components/skeletons/PlaceholderDashboards.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderRecordView.vue b/frontend/src/components/skeletons/PlaceholderRecordView.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderRecordView.vue rename to frontend/src/components/skeletons/PlaceholderRecordView.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderRowEdit.vue b/frontend/src/components/skeletons/PlaceholderRowEdit.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderRowEdit.vue rename to frontend/src/components/skeletons/PlaceholderRowEdit.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderSavedFilters.vue b/frontend/src/components/skeletons/PlaceholderSavedFilters.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderSavedFilters.vue rename to frontend/src/components/skeletons/PlaceholderSavedFilters.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderTableData.vue b/frontend/src/components/skeletons/PlaceholderTableData.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderTableData.vue rename to frontend/src/components/skeletons/PlaceholderTableData.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderTableSettings.vue b/frontend/src/components/skeletons/PlaceholderTableSettings.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderTableSettings.vue rename to frontend/src/components/skeletons/PlaceholderTableSettings.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderTableView.vue b/frontend/src/components/skeletons/PlaceholderTableView.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderTableView.vue rename to frontend/src/components/skeletons/PlaceholderTableView.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderTableWidgets.vue b/frontend/src/components/skeletons/PlaceholderTableWidgets.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderTableWidgets.vue rename to frontend/src/components/skeletons/PlaceholderTableWidgets.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderUserGroup.vue b/frontend/src/components/skeletons/PlaceholderUserGroup.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderUserGroup.vue rename to frontend/src/components/skeletons/PlaceholderUserGroup.vue diff --git a/frontend-vue/src/components/skeletons/PlaceholderUserGroups.vue b/frontend/src/components/skeletons/PlaceholderUserGroups.vue similarity index 100% rename from frontend-vue/src/components/skeletons/PlaceholderUserGroups.vue rename to frontend/src/components/skeletons/PlaceholderUserGroups.vue diff --git a/frontend-vue/src/components/skeletons/Placeholders.stories.ts b/frontend/src/components/skeletons/Placeholders.stories.ts similarity index 100% rename from frontend-vue/src/components/skeletons/Placeholders.stories.ts rename to frontend/src/components/skeletons/Placeholders.stories.ts diff --git a/frontend-vue/src/components/skeletons/placeholders.spec.ts b/frontend/src/components/skeletons/placeholders.spec.ts similarity index 100% rename from frontend-vue/src/components/skeletons/placeholders.spec.ts rename to frontend/src/components/skeletons/placeholders.spec.ts diff --git a/frontend-vue/src/components/table/ActionDeleteDialog.vue b/frontend/src/components/table/ActionDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/table/ActionDeleteDialog.vue rename to frontend/src/components/table/ActionDeleteDialog.vue diff --git a/frontend-vue/src/components/table/AutomationsEmptyState.vue b/frontend/src/components/table/AutomationsEmptyState.vue similarity index 100% rename from frontend-vue/src/components/table/AutomationsEmptyState.vue rename to frontend/src/components/table/AutomationsEmptyState.vue diff --git a/frontend-vue/src/components/table/TableWidgetItem.vue b/frontend/src/components/table/TableWidgetItem.vue similarity index 100% rename from frontend-vue/src/components/table/TableWidgetItem.vue rename to frontend/src/components/table/TableWidgetItem.vue diff --git a/frontend-vue/src/components/table/WebhookSnippets.vue b/frontend/src/components/table/WebhookSnippets.vue similarity index 100% rename from frontend-vue/src/components/table/WebhookSnippets.vue rename to frontend/src/components/table/WebhookSnippets.vue diff --git a/frontend-vue/src/components/table/WidgetDeleteDialog.vue b/frontend/src/components/table/WidgetDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/table/WidgetDeleteDialog.vue rename to frontend/src/components/table/WidgetDeleteDialog.vue diff --git a/frontend-vue/src/components/table/WidgetsEmptyState.vue b/frontend/src/components/table/WidgetsEmptyState.vue similarity index 100% rename from frontend-vue/src/components/table/WidgetsEmptyState.vue rename to frontend/src/components/table/WidgetsEmptyState.vue diff --git a/frontend-vue/src/components/table/analytics.ts b/frontend/src/components/table/analytics.ts similarity index 100% rename from frontend-vue/src/components/table/analytics.ts rename to frontend/src/components/table/analytics.ts diff --git a/frontend-vue/src/components/table/dialogs.spec.ts b/frontend/src/components/table/dialogs.spec.ts similarity index 100% rename from frontend-vue/src/components/table/dialogs.spec.ts rename to frontend/src/components/table/dialogs.spec.ts diff --git a/frontend-vue/src/components/table/widget-defaults.spec.ts b/frontend/src/components/table/widget-defaults.spec.ts similarity index 100% rename from frontend-vue/src/components/table/widget-defaults.spec.ts rename to frontend/src/components/table/widget-defaults.spec.ts diff --git a/frontend-vue/src/components/table/widget-defaults.ts b/frontend/src/components/table/widget-defaults.ts similarity index 100% rename from frontend-vue/src/components/table/widget-defaults.ts rename to frontend/src/components/table/widget-defaults.ts diff --git a/frontend-vue/src/components/ui/CONVENTIONS.md b/frontend/src/components/ui/CONVENTIONS.md similarity index 100% rename from frontend-vue/src/components/ui/CONVENTIONS.md rename to frontend/src/components/ui/CONVENTIONS.md diff --git a/frontend-vue/src/components/ui/UiAccordion.vue b/frontend/src/components/ui/UiAccordion.vue similarity index 100% rename from frontend-vue/src/components/ui/UiAccordion.vue rename to frontend/src/components/ui/UiAccordion.vue diff --git a/frontend-vue/src/components/ui/UiAlert.spec.ts b/frontend/src/components/ui/UiAlert.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiAlert.spec.ts rename to frontend/src/components/ui/UiAlert.spec.ts diff --git a/frontend-vue/src/components/ui/UiAlert.stories.ts b/frontend/src/components/ui/UiAlert.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiAlert.stories.ts rename to frontend/src/components/ui/UiAlert.stories.ts diff --git a/frontend-vue/src/components/ui/UiAlert.vue b/frontend/src/components/ui/UiAlert.vue similarity index 100% rename from frontend-vue/src/components/ui/UiAlert.vue rename to frontend/src/components/ui/UiAlert.vue diff --git a/frontend-vue/src/components/ui/UiAutocomplete.spec.ts b/frontend/src/components/ui/UiAutocomplete.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiAutocomplete.spec.ts rename to frontend/src/components/ui/UiAutocomplete.spec.ts diff --git a/frontend-vue/src/components/ui/UiAutocomplete.stories.ts b/frontend/src/components/ui/UiAutocomplete.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiAutocomplete.stories.ts rename to frontend/src/components/ui/UiAutocomplete.stories.ts diff --git a/frontend-vue/src/components/ui/UiAutocomplete.vue b/frontend/src/components/ui/UiAutocomplete.vue similarity index 100% rename from frontend-vue/src/components/ui/UiAutocomplete.vue rename to frontend/src/components/ui/UiAutocomplete.vue diff --git a/frontend-vue/src/components/ui/UiBadge.spec.ts b/frontend/src/components/ui/UiBadge.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiBadge.spec.ts rename to frontend/src/components/ui/UiBadge.spec.ts diff --git a/frontend-vue/src/components/ui/UiBadge.stories.ts b/frontend/src/components/ui/UiBadge.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiBadge.stories.ts rename to frontend/src/components/ui/UiBadge.stories.ts diff --git a/frontend-vue/src/components/ui/UiBadge.vue b/frontend/src/components/ui/UiBadge.vue similarity index 100% rename from frontend-vue/src/components/ui/UiBadge.vue rename to frontend/src/components/ui/UiBadge.vue diff --git a/frontend-vue/src/components/ui/UiButton.spec.ts b/frontend/src/components/ui/UiButton.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiButton.spec.ts rename to frontend/src/components/ui/UiButton.spec.ts diff --git a/frontend-vue/src/components/ui/UiButton.stories.ts b/frontend/src/components/ui/UiButton.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiButton.stories.ts rename to frontend/src/components/ui/UiButton.stories.ts diff --git a/frontend-vue/src/components/ui/UiButton.vue b/frontend/src/components/ui/UiButton.vue similarity index 100% rename from frontend-vue/src/components/ui/UiButton.vue rename to frontend/src/components/ui/UiButton.vue diff --git a/frontend-vue/src/components/ui/UiCard.spec.ts b/frontend/src/components/ui/UiCard.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiCard.spec.ts rename to frontend/src/components/ui/UiCard.spec.ts diff --git a/frontend-vue/src/components/ui/UiCard.stories.ts b/frontend/src/components/ui/UiCard.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiCard.stories.ts rename to frontend/src/components/ui/UiCard.stories.ts diff --git a/frontend-vue/src/components/ui/UiCard.vue b/frontend/src/components/ui/UiCard.vue similarity index 100% rename from frontend-vue/src/components/ui/UiCard.vue rename to frontend/src/components/ui/UiCard.vue diff --git a/frontend-vue/src/components/ui/UiCheckbox.spec.ts b/frontend/src/components/ui/UiCheckbox.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiCheckbox.spec.ts rename to frontend/src/components/ui/UiCheckbox.spec.ts diff --git a/frontend-vue/src/components/ui/UiCheckbox.stories.ts b/frontend/src/components/ui/UiCheckbox.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiCheckbox.stories.ts rename to frontend/src/components/ui/UiCheckbox.stories.ts diff --git a/frontend-vue/src/components/ui/UiCheckbox.vue b/frontend/src/components/ui/UiCheckbox.vue similarity index 100% rename from frontend-vue/src/components/ui/UiCheckbox.vue rename to frontend/src/components/ui/UiCheckbox.vue diff --git a/frontend-vue/src/components/ui/UiChip.spec.ts b/frontend/src/components/ui/UiChip.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiChip.spec.ts rename to frontend/src/components/ui/UiChip.spec.ts diff --git a/frontend-vue/src/components/ui/UiChip.stories.ts b/frontend/src/components/ui/UiChip.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiChip.stories.ts rename to frontend/src/components/ui/UiChip.stories.ts diff --git a/frontend-vue/src/components/ui/UiChip.vue b/frontend/src/components/ui/UiChip.vue similarity index 100% rename from frontend-vue/src/components/ui/UiChip.vue rename to frontend/src/components/ui/UiChip.vue diff --git a/frontend-vue/src/components/ui/UiConfirmDialog.spec.ts b/frontend/src/components/ui/UiConfirmDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiConfirmDialog.spec.ts rename to frontend/src/components/ui/UiConfirmDialog.spec.ts diff --git a/frontend-vue/src/components/ui/UiConfirmDialog.stories.ts b/frontend/src/components/ui/UiConfirmDialog.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiConfirmDialog.stories.ts rename to frontend/src/components/ui/UiConfirmDialog.stories.ts diff --git a/frontend-vue/src/components/ui/UiConfirmDialog.vue b/frontend/src/components/ui/UiConfirmDialog.vue similarity index 100% rename from frontend-vue/src/components/ui/UiConfirmDialog.vue rename to frontend/src/components/ui/UiConfirmDialog.vue diff --git a/frontend-vue/src/components/ui/UiDivider.spec.ts b/frontend/src/components/ui/UiDivider.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiDivider.spec.ts rename to frontend/src/components/ui/UiDivider.spec.ts diff --git a/frontend-vue/src/components/ui/UiDivider.stories.ts b/frontend/src/components/ui/UiDivider.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiDivider.stories.ts rename to frontend/src/components/ui/UiDivider.stories.ts diff --git a/frontend-vue/src/components/ui/UiDivider.vue b/frontend/src/components/ui/UiDivider.vue similarity index 100% rename from frontend-vue/src/components/ui/UiDivider.vue rename to frontend/src/components/ui/UiDivider.vue diff --git a/frontend-vue/src/components/ui/UiDrawer.spec.ts b/frontend/src/components/ui/UiDrawer.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiDrawer.spec.ts rename to frontend/src/components/ui/UiDrawer.spec.ts diff --git a/frontend-vue/src/components/ui/UiDrawer.stories.ts b/frontend/src/components/ui/UiDrawer.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiDrawer.stories.ts rename to frontend/src/components/ui/UiDrawer.stories.ts diff --git a/frontend-vue/src/components/ui/UiDrawer.vue b/frontend/src/components/ui/UiDrawer.vue similarity index 100% rename from frontend-vue/src/components/ui/UiDrawer.vue rename to frontend/src/components/ui/UiDrawer.vue diff --git a/frontend-vue/src/components/ui/UiExpansionPanel.spec.ts b/frontend/src/components/ui/UiExpansionPanel.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiExpansionPanel.spec.ts rename to frontend/src/components/ui/UiExpansionPanel.spec.ts diff --git a/frontend-vue/src/components/ui/UiExpansionPanel.stories.ts b/frontend/src/components/ui/UiExpansionPanel.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiExpansionPanel.stories.ts rename to frontend/src/components/ui/UiExpansionPanel.stories.ts diff --git a/frontend-vue/src/components/ui/UiExpansionPanel.vue b/frontend/src/components/ui/UiExpansionPanel.vue similarity index 100% rename from frontend-vue/src/components/ui/UiExpansionPanel.vue rename to frontend/src/components/ui/UiExpansionPanel.vue diff --git a/frontend-vue/src/components/ui/UiField.spec.ts b/frontend/src/components/ui/UiField.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiField.spec.ts rename to frontend/src/components/ui/UiField.spec.ts diff --git a/frontend-vue/src/components/ui/UiField.stories.ts b/frontend/src/components/ui/UiField.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiField.stories.ts rename to frontend/src/components/ui/UiField.stories.ts diff --git a/frontend-vue/src/components/ui/UiField.vue b/frontend/src/components/ui/UiField.vue similarity index 100% rename from frontend-vue/src/components/ui/UiField.vue rename to frontend/src/components/ui/UiField.vue diff --git a/frontend-vue/src/components/ui/UiIcon.spec.ts b/frontend/src/components/ui/UiIcon.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiIcon.spec.ts rename to frontend/src/components/ui/UiIcon.spec.ts diff --git a/frontend-vue/src/components/ui/UiIcon.stories.ts b/frontend/src/components/ui/UiIcon.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiIcon.stories.ts rename to frontend/src/components/ui/UiIcon.stories.ts diff --git a/frontend-vue/src/components/ui/UiIcon.vue b/frontend/src/components/ui/UiIcon.vue similarity index 100% rename from frontend-vue/src/components/ui/UiIcon.vue rename to frontend/src/components/ui/UiIcon.vue diff --git a/frontend-vue/src/components/ui/UiIconButton.spec.ts b/frontend/src/components/ui/UiIconButton.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiIconButton.spec.ts rename to frontend/src/components/ui/UiIconButton.spec.ts diff --git a/frontend-vue/src/components/ui/UiIconButton.stories.ts b/frontend/src/components/ui/UiIconButton.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiIconButton.stories.ts rename to frontend/src/components/ui/UiIconButton.stories.ts diff --git a/frontend-vue/src/components/ui/UiIconButton.vue b/frontend/src/components/ui/UiIconButton.vue similarity index 100% rename from frontend-vue/src/components/ui/UiIconButton.vue rename to frontend/src/components/ui/UiIconButton.vue diff --git a/frontend-vue/src/components/ui/UiInput.spec.ts b/frontend/src/components/ui/UiInput.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiInput.spec.ts rename to frontend/src/components/ui/UiInput.spec.ts diff --git a/frontend-vue/src/components/ui/UiInput.vue b/frontend/src/components/ui/UiInput.vue similarity index 100% rename from frontend-vue/src/components/ui/UiInput.vue rename to frontend/src/components/ui/UiInput.vue diff --git a/frontend-vue/src/components/ui/UiList.spec.ts b/frontend/src/components/ui/UiList.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiList.spec.ts rename to frontend/src/components/ui/UiList.spec.ts diff --git a/frontend-vue/src/components/ui/UiList.stories.ts b/frontend/src/components/ui/UiList.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiList.stories.ts rename to frontend/src/components/ui/UiList.stories.ts diff --git a/frontend-vue/src/components/ui/UiList.vue b/frontend/src/components/ui/UiList.vue similarity index 100% rename from frontend-vue/src/components/ui/UiList.vue rename to frontend/src/components/ui/UiList.vue diff --git a/frontend-vue/src/components/ui/UiListItem.vue b/frontend/src/components/ui/UiListItem.vue similarity index 100% rename from frontend-vue/src/components/ui/UiListItem.vue rename to frontend/src/components/ui/UiListItem.vue diff --git a/frontend-vue/src/components/ui/UiMenu.spec.ts b/frontend/src/components/ui/UiMenu.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiMenu.spec.ts rename to frontend/src/components/ui/UiMenu.spec.ts diff --git a/frontend-vue/src/components/ui/UiMenu.stories.ts b/frontend/src/components/ui/UiMenu.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiMenu.stories.ts rename to frontend/src/components/ui/UiMenu.stories.ts diff --git a/frontend-vue/src/components/ui/UiMenu.vue b/frontend/src/components/ui/UiMenu.vue similarity index 100% rename from frontend-vue/src/components/ui/UiMenu.vue rename to frontend/src/components/ui/UiMenu.vue diff --git a/frontend-vue/src/components/ui/UiMenuItem.vue b/frontend/src/components/ui/UiMenuItem.vue similarity index 100% rename from frontend-vue/src/components/ui/UiMenuItem.vue rename to frontend/src/components/ui/UiMenuItem.vue diff --git a/frontend-vue/src/components/ui/UiModal.spec.ts b/frontend/src/components/ui/UiModal.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiModal.spec.ts rename to frontend/src/components/ui/UiModal.spec.ts diff --git a/frontend-vue/src/components/ui/UiModal.stories.ts b/frontend/src/components/ui/UiModal.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiModal.stories.ts rename to frontend/src/components/ui/UiModal.stories.ts diff --git a/frontend-vue/src/components/ui/UiModal.vue b/frontend/src/components/ui/UiModal.vue similarity index 100% rename from frontend-vue/src/components/ui/UiModal.vue rename to frontend/src/components/ui/UiModal.vue diff --git a/frontend-vue/src/components/ui/UiPageShell.spec.ts b/frontend/src/components/ui/UiPageShell.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiPageShell.spec.ts rename to frontend/src/components/ui/UiPageShell.spec.ts diff --git a/frontend-vue/src/components/ui/UiPageShell.stories.ts b/frontend/src/components/ui/UiPageShell.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiPageShell.stories.ts rename to frontend/src/components/ui/UiPageShell.stories.ts diff --git a/frontend-vue/src/components/ui/UiPageShell.vue b/frontend/src/components/ui/UiPageShell.vue similarity index 100% rename from frontend-vue/src/components/ui/UiPageShell.vue rename to frontend/src/components/ui/UiPageShell.vue diff --git a/frontend-vue/src/components/ui/UiPaginator.spec.ts b/frontend/src/components/ui/UiPaginator.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiPaginator.spec.ts rename to frontend/src/components/ui/UiPaginator.spec.ts diff --git a/frontend-vue/src/components/ui/UiPaginator.stories.ts b/frontend/src/components/ui/UiPaginator.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiPaginator.stories.ts rename to frontend/src/components/ui/UiPaginator.stories.ts diff --git a/frontend-vue/src/components/ui/UiPaginator.vue b/frontend/src/components/ui/UiPaginator.vue similarity index 100% rename from frontend-vue/src/components/ui/UiPaginator.vue rename to frontend/src/components/ui/UiPaginator.vue diff --git a/frontend-vue/src/components/ui/UiProgressBar.spec.ts b/frontend/src/components/ui/UiProgressBar.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiProgressBar.spec.ts rename to frontend/src/components/ui/UiProgressBar.spec.ts diff --git a/frontend-vue/src/components/ui/UiProgressBar.stories.ts b/frontend/src/components/ui/UiProgressBar.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiProgressBar.stories.ts rename to frontend/src/components/ui/UiProgressBar.stories.ts diff --git a/frontend-vue/src/components/ui/UiProgressBar.vue b/frontend/src/components/ui/UiProgressBar.vue similarity index 100% rename from frontend-vue/src/components/ui/UiProgressBar.vue rename to frontend/src/components/ui/UiProgressBar.vue diff --git a/frontend-vue/src/components/ui/UiRadio.vue b/frontend/src/components/ui/UiRadio.vue similarity index 100% rename from frontend-vue/src/components/ui/UiRadio.vue rename to frontend/src/components/ui/UiRadio.vue diff --git a/frontend-vue/src/components/ui/UiRadioGroup.spec.ts b/frontend/src/components/ui/UiRadioGroup.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiRadioGroup.spec.ts rename to frontend/src/components/ui/UiRadioGroup.spec.ts diff --git a/frontend-vue/src/components/ui/UiRadioGroup.stories.ts b/frontend/src/components/ui/UiRadioGroup.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiRadioGroup.stories.ts rename to frontend/src/components/ui/UiRadioGroup.stories.ts diff --git a/frontend-vue/src/components/ui/UiRadioGroup.vue b/frontend/src/components/ui/UiRadioGroup.vue similarity index 100% rename from frontend-vue/src/components/ui/UiRadioGroup.vue rename to frontend/src/components/ui/UiRadioGroup.vue diff --git a/frontend-vue/src/components/ui/UiSegmented.spec.ts b/frontend/src/components/ui/UiSegmented.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSegmented.spec.ts rename to frontend/src/components/ui/UiSegmented.spec.ts diff --git a/frontend-vue/src/components/ui/UiSegmented.stories.ts b/frontend/src/components/ui/UiSegmented.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSegmented.stories.ts rename to frontend/src/components/ui/UiSegmented.stories.ts diff --git a/frontend-vue/src/components/ui/UiSegmented.vue b/frontend/src/components/ui/UiSegmented.vue similarity index 100% rename from frontend-vue/src/components/ui/UiSegmented.vue rename to frontend/src/components/ui/UiSegmented.vue diff --git a/frontend-vue/src/components/ui/UiSelect.spec.ts b/frontend/src/components/ui/UiSelect.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSelect.spec.ts rename to frontend/src/components/ui/UiSelect.spec.ts diff --git a/frontend-vue/src/components/ui/UiSelect.stories.ts b/frontend/src/components/ui/UiSelect.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSelect.stories.ts rename to frontend/src/components/ui/UiSelect.stories.ts diff --git a/frontend-vue/src/components/ui/UiSelect.vue b/frontend/src/components/ui/UiSelect.vue similarity index 100% rename from frontend-vue/src/components/ui/UiSelect.vue rename to frontend/src/components/ui/UiSelect.vue diff --git a/frontend-vue/src/components/ui/UiSkeleton.spec.ts b/frontend/src/components/ui/UiSkeleton.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSkeleton.spec.ts rename to frontend/src/components/ui/UiSkeleton.spec.ts diff --git a/frontend-vue/src/components/ui/UiSkeleton.stories.ts b/frontend/src/components/ui/UiSkeleton.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSkeleton.stories.ts rename to frontend/src/components/ui/UiSkeleton.stories.ts diff --git a/frontend-vue/src/components/ui/UiSkeleton.vue b/frontend/src/components/ui/UiSkeleton.vue similarity index 100% rename from frontend-vue/src/components/ui/UiSkeleton.vue rename to frontend/src/components/ui/UiSkeleton.vue diff --git a/frontend-vue/src/components/ui/UiSpinner.spec.ts b/frontend/src/components/ui/UiSpinner.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSpinner.spec.ts rename to frontend/src/components/ui/UiSpinner.spec.ts diff --git a/frontend-vue/src/components/ui/UiSpinner.stories.ts b/frontend/src/components/ui/UiSpinner.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSpinner.stories.ts rename to frontend/src/components/ui/UiSpinner.stories.ts diff --git a/frontend-vue/src/components/ui/UiSpinner.vue b/frontend/src/components/ui/UiSpinner.vue similarity index 100% rename from frontend-vue/src/components/ui/UiSpinner.vue rename to frontend/src/components/ui/UiSpinner.vue diff --git a/frontend-vue/src/components/ui/UiSwitch.spec.ts b/frontend/src/components/ui/UiSwitch.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSwitch.spec.ts rename to frontend/src/components/ui/UiSwitch.spec.ts diff --git a/frontend-vue/src/components/ui/UiSwitch.stories.ts b/frontend/src/components/ui/UiSwitch.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiSwitch.stories.ts rename to frontend/src/components/ui/UiSwitch.stories.ts diff --git a/frontend-vue/src/components/ui/UiSwitch.vue b/frontend/src/components/ui/UiSwitch.vue similarity index 100% rename from frontend-vue/src/components/ui/UiSwitch.vue rename to frontend/src/components/ui/UiSwitch.vue diff --git a/frontend-vue/src/components/ui/UiTab.vue b/frontend/src/components/ui/UiTab.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTab.vue rename to frontend/src/components/ui/UiTab.vue diff --git a/frontend-vue/src/components/ui/UiTabPanel.vue b/frontend/src/components/ui/UiTabPanel.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTabPanel.vue rename to frontend/src/components/ui/UiTabPanel.vue diff --git a/frontend-vue/src/components/ui/UiTable.spec.ts b/frontend/src/components/ui/UiTable.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTable.spec.ts rename to frontend/src/components/ui/UiTable.spec.ts diff --git a/frontend-vue/src/components/ui/UiTable.stories.ts b/frontend/src/components/ui/UiTable.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTable.stories.ts rename to frontend/src/components/ui/UiTable.stories.ts diff --git a/frontend-vue/src/components/ui/UiTable.vue b/frontend/src/components/ui/UiTable.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTable.vue rename to frontend/src/components/ui/UiTable.vue diff --git a/frontend-vue/src/components/ui/UiTabs.spec.ts b/frontend/src/components/ui/UiTabs.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTabs.spec.ts rename to frontend/src/components/ui/UiTabs.spec.ts diff --git a/frontend-vue/src/components/ui/UiTabs.stories.ts b/frontend/src/components/ui/UiTabs.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTabs.stories.ts rename to frontend/src/components/ui/UiTabs.stories.ts diff --git a/frontend-vue/src/components/ui/UiTabs.vue b/frontend/src/components/ui/UiTabs.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTabs.vue rename to frontend/src/components/ui/UiTabs.vue diff --git a/frontend-vue/src/components/ui/UiTextarea.spec.ts b/frontend/src/components/ui/UiTextarea.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTextarea.spec.ts rename to frontend/src/components/ui/UiTextarea.spec.ts diff --git a/frontend-vue/src/components/ui/UiTextarea.stories.ts b/frontend/src/components/ui/UiTextarea.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/UiTextarea.stories.ts rename to frontend/src/components/ui/UiTextarea.stories.ts diff --git a/frontend-vue/src/components/ui/UiTextarea.vue b/frontend/src/components/ui/UiTextarea.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTextarea.vue rename to frontend/src/components/ui/UiTextarea.vue diff --git a/frontend-vue/src/components/ui/UiTooltipLayer.vue b/frontend/src/components/ui/UiTooltipLayer.vue similarity index 100% rename from frontend-vue/src/components/ui/UiTooltipLayer.vue rename to frontend/src/components/ui/UiTooltipLayer.vue diff --git a/frontend-vue/src/components/ui/__tests__/a11y.spec.ts b/frontend/src/components/ui/__tests__/a11y.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/__tests__/a11y.spec.ts rename to frontend/src/components/ui/__tests__/a11y.spec.ts diff --git a/frontend-vue/src/components/ui/__tests__/axe.ts b/frontend/src/components/ui/__tests__/axe.ts similarity index 100% rename from frontend-vue/src/components/ui/__tests__/axe.ts rename to frontend/src/components/ui/__tests__/axe.ts diff --git a/frontend-vue/src/components/ui/accordionContext.ts b/frontend/src/components/ui/accordionContext.ts similarity index 100% rename from frontend-vue/src/components/ui/accordionContext.ts rename to frontend/src/components/ui/accordionContext.ts diff --git a/frontend-vue/src/components/ui/docs/00-GettingStarted.mdx b/frontend/src/components/ui/docs/00-GettingStarted.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/00-GettingStarted.mdx rename to frontend/src/components/ui/docs/00-GettingStarted.mdx diff --git a/frontend-vue/src/components/ui/docs/01-Conventions.mdx b/frontend/src/components/ui/docs/01-Conventions.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/01-Conventions.mdx rename to frontend/src/components/ui/docs/01-Conventions.mdx diff --git a/frontend-vue/src/components/ui/docs/02-Color.mdx b/frontend/src/components/ui/docs/02-Color.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/02-Color.mdx rename to frontend/src/components/ui/docs/02-Color.mdx diff --git a/frontend-vue/src/components/ui/docs/03-Typography.mdx b/frontend/src/components/ui/docs/03-Typography.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/03-Typography.mdx rename to frontend/src/components/ui/docs/03-Typography.mdx diff --git a/frontend-vue/src/components/ui/docs/04-Spacing.mdx b/frontend/src/components/ui/docs/04-Spacing.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/04-Spacing.mdx rename to frontend/src/components/ui/docs/04-Spacing.mdx diff --git a/frontend-vue/src/components/ui/docs/05-Shape.mdx b/frontend/src/components/ui/docs/05-Shape.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/05-Shape.mdx rename to frontend/src/components/ui/docs/05-Shape.mdx diff --git a/frontend-vue/src/components/ui/docs/06-Elevation.mdx b/frontend/src/components/ui/docs/06-Elevation.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/06-Elevation.mdx rename to frontend/src/components/ui/docs/06-Elevation.mdx diff --git a/frontend-vue/src/components/ui/docs/07-Motion.mdx b/frontend/src/components/ui/docs/07-Motion.mdx similarity index 100% rename from frontend-vue/src/components/ui/docs/07-Motion.mdx rename to frontend/src/components/ui/docs/07-Motion.mdx diff --git a/frontend-vue/src/components/ui/docs/ScaleSpecimens.vue b/frontend/src/components/ui/docs/ScaleSpecimens.vue similarity index 100% rename from frontend-vue/src/components/ui/docs/ScaleSpecimens.vue rename to frontend/src/components/ui/docs/ScaleSpecimens.vue diff --git a/frontend-vue/src/components/ui/docs/SpecimenList.vue b/frontend/src/components/ui/docs/SpecimenList.vue similarity index 100% rename from frontend-vue/src/components/ui/docs/SpecimenList.vue rename to frontend/src/components/ui/docs/SpecimenList.vue diff --git a/frontend-vue/src/components/ui/docs/SwatchGrid.vue b/frontend/src/components/ui/docs/SwatchGrid.vue similarity index 100% rename from frontend-vue/src/components/ui/docs/SwatchGrid.vue rename to frontend/src/components/ui/docs/SwatchGrid.vue diff --git a/frontend-vue/src/components/ui/docs/TokenTable.vue b/frontend/src/components/ui/docs/TokenTable.vue similarity index 100% rename from frontend-vue/src/components/ui/docs/TokenTable.vue rename to frontend/src/components/ui/docs/TokenTable.vue diff --git a/frontend-vue/src/components/ui/docs/TypeSpecimens.vue b/frontend/src/components/ui/docs/TypeSpecimens.vue similarity index 100% rename from frontend-vue/src/components/ui/docs/TypeSpecimens.vue rename to frontend/src/components/ui/docs/TypeSpecimens.vue diff --git a/frontend-vue/src/components/ui/docs/conventions.spec.ts b/frontend/src/components/ui/docs/conventions.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/docs/conventions.spec.ts rename to frontend/src/components/ui/docs/conventions.spec.ts diff --git a/frontend-vue/src/components/ui/docs/foundations.stories.ts b/frontend/src/components/ui/docs/foundations.stories.ts similarity index 100% rename from frontend-vue/src/components/ui/docs/foundations.stories.ts rename to frontend/src/components/ui/docs/foundations.stories.ts diff --git a/frontend-vue/src/components/ui/docs/readTokens.spec.ts b/frontend/src/components/ui/docs/readTokens.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/docs/readTokens.spec.ts rename to frontend/src/components/ui/docs/readTokens.spec.ts diff --git a/frontend-vue/src/components/ui/docs/readTokens.ts b/frontend/src/components/ui/docs/readTokens.ts similarity index 100% rename from frontend-vue/src/components/ui/docs/readTokens.ts rename to frontend/src/components/ui/docs/readTokens.ts diff --git a/frontend-vue/src/components/ui/docs/specimens.spec.ts b/frontend/src/components/ui/docs/specimens.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/docs/specimens.spec.ts rename to frontend/src/components/ui/docs/specimens.spec.ts diff --git a/frontend-vue/src/components/ui/fieldContext.ts b/frontend/src/components/ui/fieldContext.ts similarity index 100% rename from frontend-vue/src/components/ui/fieldContext.ts rename to frontend/src/components/ui/fieldContext.ts diff --git a/frontend-vue/src/components/ui/index.ts b/frontend/src/components/ui/index.ts similarity index 100% rename from frontend-vue/src/components/ui/index.ts rename to frontend/src/components/ui/index.ts diff --git a/frontend-vue/src/components/ui/menuContext.ts b/frontend/src/components/ui/menuContext.ts similarity index 100% rename from frontend-vue/src/components/ui/menuContext.ts rename to frontend/src/components/ui/menuContext.ts diff --git a/frontend-vue/src/components/ui/radioContext.ts b/frontend/src/components/ui/radioContext.ts similarity index 100% rename from frontend-vue/src/components/ui/radioContext.ts rename to frontend/src/components/ui/radioContext.ts diff --git a/frontend-vue/src/components/ui/selectOption.ts b/frontend/src/components/ui/selectOption.ts similarity index 100% rename from frontend-vue/src/components/ui/selectOption.ts rename to frontend/src/components/ui/selectOption.ts diff --git a/frontend-vue/src/components/ui/tableTypes.ts b/frontend/src/components/ui/tableTypes.ts similarity index 100% rename from frontend-vue/src/components/ui/tableTypes.ts rename to frontend/src/components/ui/tableTypes.ts diff --git a/frontend-vue/src/components/ui/tabsContext.ts b/frontend/src/components/ui/tabsContext.ts similarity index 100% rename from frontend-vue/src/components/ui/tabsContext.ts rename to frontend/src/components/ui/tabsContext.ts diff --git a/frontend-vue/src/components/ui/tooltip.spec.ts b/frontend/src/components/ui/tooltip.spec.ts similarity index 100% rename from frontend-vue/src/components/ui/tooltip.spec.ts rename to frontend/src/components/ui/tooltip.spec.ts diff --git a/frontend-vue/src/components/ui/tooltip.ts b/frontend/src/components/ui/tooltip.ts similarity index 100% rename from frontend-vue/src/components/ui/tooltip.ts rename to frontend/src/components/ui/tooltip.ts diff --git a/frontend-vue/src/components/ui/tooltipState.ts b/frontend/src/components/ui/tooltipState.ts similarity index 100% rename from frontend-vue/src/components/ui/tooltipState.ts rename to frontend/src/components/ui/tooltipState.ts diff --git a/frontend-vue/src/components/user-settings/AccountDeleteConfirmationDialog.spec.ts b/frontend/src/components/user-settings/AccountDeleteConfirmationDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/user-settings/AccountDeleteConfirmationDialog.spec.ts rename to frontend/src/components/user-settings/AccountDeleteConfirmationDialog.spec.ts diff --git a/frontend-vue/src/components/user-settings/AccountDeleteConfirmationDialog.vue b/frontend/src/components/user-settings/AccountDeleteConfirmationDialog.vue similarity index 100% rename from frontend-vue/src/components/user-settings/AccountDeleteConfirmationDialog.vue rename to frontend/src/components/user-settings/AccountDeleteConfirmationDialog.vue diff --git a/frontend-vue/src/components/user-settings/AccountDeleteDialog.spec.ts b/frontend/src/components/user-settings/AccountDeleteDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/user-settings/AccountDeleteDialog.spec.ts rename to frontend/src/components/user-settings/AccountDeleteDialog.spec.ts diff --git a/frontend-vue/src/components/user-settings/AccountDeleteDialog.vue b/frontend/src/components/user-settings/AccountDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/user-settings/AccountDeleteDialog.vue rename to frontend/src/components/user-settings/AccountDeleteDialog.vue diff --git a/frontend-vue/src/components/user-settings/ApiKeyDeleteDialog.spec.ts b/frontend/src/components/user-settings/ApiKeyDeleteDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/user-settings/ApiKeyDeleteDialog.spec.ts rename to frontend/src/components/user-settings/ApiKeyDeleteDialog.spec.ts diff --git a/frontend-vue/src/components/user-settings/ApiKeyDeleteDialog.vue b/frontend/src/components/user-settings/ApiKeyDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/user-settings/ApiKeyDeleteDialog.vue rename to frontend/src/components/user-settings/ApiKeyDeleteDialog.vue diff --git a/frontend-vue/src/components/user-settings/EnableTwoFaDialog.spec.ts b/frontend/src/components/user-settings/EnableTwoFaDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/user-settings/EnableTwoFaDialog.spec.ts rename to frontend/src/components/user-settings/EnableTwoFaDialog.spec.ts diff --git a/frontend-vue/src/components/user-settings/EnableTwoFaDialog.vue b/frontend/src/components/user-settings/EnableTwoFaDialog.vue similarity index 100% rename from frontend-vue/src/components/user-settings/EnableTwoFaDialog.vue rename to frontend/src/components/user-settings/EnableTwoFaDialog.vue diff --git a/frontend-vue/src/components/user-settings/dialogTestUtils.ts b/frontend/src/components/user-settings/dialogTestUtils.ts similarity index 100% rename from frontend-vue/src/components/user-settings/dialogTestUtils.ts rename to frontend/src/components/user-settings/dialogTestUtils.ts diff --git a/frontend-vue/src/components/users/CedarPolicyEditorDialog.spec.ts b/frontend/src/components/users/CedarPolicyEditorDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/users/CedarPolicyEditorDialog.spec.ts rename to frontend/src/components/users/CedarPolicyEditorDialog.spec.ts diff --git a/frontend-vue/src/components/users/CedarPolicyEditorDialog.vue b/frontend/src/components/users/CedarPolicyEditorDialog.vue similarity index 100% rename from frontend-vue/src/components/users/CedarPolicyEditorDialog.vue rename to frontend/src/components/users/CedarPolicyEditorDialog.vue diff --git a/frontend-vue/src/components/users/CedarPolicyItemForm.vue b/frontend/src/components/users/CedarPolicyItemForm.vue similarity index 100% rename from frontend-vue/src/components/users/CedarPolicyItemForm.vue rename to frontend/src/components/users/CedarPolicyItemForm.vue diff --git a/frontend-vue/src/components/users/CedarPolicyList.spec.ts b/frontend/src/components/users/CedarPolicyList.spec.ts similarity index 100% rename from frontend-vue/src/components/users/CedarPolicyList.spec.ts rename to frontend/src/components/users/CedarPolicyList.spec.ts diff --git a/frontend-vue/src/components/users/CedarPolicyList.vue b/frontend/src/components/users/CedarPolicyList.vue similarity index 100% rename from frontend-vue/src/components/users/CedarPolicyList.vue rename to frontend/src/components/users/CedarPolicyList.vue diff --git a/frontend-vue/src/components/users/GroupAddDialog.vue b/frontend/src/components/users/GroupAddDialog.vue similarity index 100% rename from frontend-vue/src/components/users/GroupAddDialog.vue rename to frontend/src/components/users/GroupAddDialog.vue diff --git a/frontend-vue/src/components/users/GroupDeleteDialog.vue b/frontend/src/components/users/GroupDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/users/GroupDeleteDialog.vue rename to frontend/src/components/users/GroupDeleteDialog.vue diff --git a/frontend-vue/src/components/users/GroupDialogs.spec.ts b/frontend/src/components/users/GroupDialogs.spec.ts similarity index 100% rename from frontend-vue/src/components/users/GroupDialogs.spec.ts rename to frontend/src/components/users/GroupDialogs.spec.ts diff --git a/frontend-vue/src/components/users/GroupNameEditDialog.vue b/frontend/src/components/users/GroupNameEditDialog.vue similarity index 100% rename from frontend-vue/src/components/users/GroupNameEditDialog.vue rename to frontend/src/components/users/GroupNameEditDialog.vue diff --git a/frontend-vue/src/components/users/PublicAccessPanel.spec.ts b/frontend/src/components/users/PublicAccessPanel.spec.ts similarity index 100% rename from frontend-vue/src/components/users/PublicAccessPanel.spec.ts rename to frontend/src/components/users/PublicAccessPanel.spec.ts diff --git a/frontend-vue/src/components/users/PublicAccessPanel.vue b/frontend/src/components/users/PublicAccessPanel.vue similarity index 100% rename from frontend-vue/src/components/users/PublicAccessPanel.vue rename to frontend/src/components/users/PublicAccessPanel.vue diff --git a/frontend-vue/src/components/users/UserAddDialog.spec.ts b/frontend/src/components/users/UserAddDialog.spec.ts similarity index 100% rename from frontend-vue/src/components/users/UserAddDialog.spec.ts rename to frontend/src/components/users/UserAddDialog.spec.ts diff --git a/frontend-vue/src/components/users/UserAddDialog.vue b/frontend/src/components/users/UserAddDialog.vue similarity index 100% rename from frontend-vue/src/components/users/UserAddDialog.vue rename to frontend/src/components/users/UserAddDialog.vue diff --git a/frontend-vue/src/components/users/UserDeleteDialog.vue b/frontend/src/components/users/UserDeleteDialog.vue similarity index 100% rename from frontend-vue/src/components/users/UserDeleteDialog.vue rename to frontend/src/components/users/UserDeleteDialog.vue diff --git a/frontend-vue/src/components/users/analytics.ts b/frontend/src/components/users/analytics.ts similarity index 100% rename from frontend-vue/src/components/users/analytics.ts rename to frontend/src/components/users/analytics.ts diff --git a/frontend-vue/src/components/users/policyList.ts b/frontend/src/components/users/policyList.ts similarity index 100% rename from frontend-vue/src/components/users/policyList.ts rename to frontend/src/components/users/policyList.ts diff --git a/frontend-vue/src/components/users/testUtils.ts b/frontend/src/components/users/testUtils.ts similarity index 100% rename from frontend-vue/src/components/users/testUtils.ts rename to frontend/src/components/users/testUtils.ts diff --git a/frontend-vue/src/components/users/types.ts b/frontend/src/components/users/types.ts similarity index 100% rename from frontend-vue/src/components/users/types.ts rename to frontend/src/components/users/types.ts diff --git a/frontend-vue/src/composables/useAnalytics.ts b/frontend/src/composables/useAnalytics.ts similarity index 100% rename from frontend-vue/src/composables/useAnalytics.ts rename to frontend/src/composables/useAnalytics.ts diff --git a/frontend-vue/src/composables/useAppSession.spec.ts b/frontend/src/composables/useAppSession.spec.ts similarity index 100% rename from frontend-vue/src/composables/useAppSession.spec.ts rename to frontend/src/composables/useAppSession.spec.ts diff --git a/frontend-vue/src/composables/useAppSession.ts b/frontend/src/composables/useAppSession.ts similarity index 100% rename from frontend-vue/src/composables/useAppSession.ts rename to frontend/src/composables/useAppSession.ts diff --git a/frontend-vue/src/composables/useCedarValidator.spec.ts b/frontend/src/composables/useCedarValidator.spec.ts similarity index 100% rename from frontend-vue/src/composables/useCedarValidator.spec.ts rename to frontend/src/composables/useCedarValidator.spec.ts diff --git a/frontend-vue/src/composables/useCedarValidator.ts b/frontend/src/composables/useCedarValidator.ts similarity index 100% rename from frontend-vue/src/composables/useCedarValidator.ts rename to frontend/src/composables/useCedarValidator.ts diff --git a/frontend-vue/src/composables/useDialog.ts b/frontend/src/composables/useDialog.ts similarity index 100% rename from frontend-vue/src/composables/useDialog.ts rename to frontend/src/composables/useDialog.ts diff --git a/frontend-vue/src/composables/useGoogleSignIn.spec.ts b/frontend/src/composables/useGoogleSignIn.spec.ts similarity index 100% rename from frontend-vue/src/composables/useGoogleSignIn.spec.ts rename to frontend/src/composables/useGoogleSignIn.spec.ts diff --git a/frontend-vue/src/composables/useGoogleSignIn.ts b/frontend/src/composables/useGoogleSignIn.ts similarity index 100% rename from frontend-vue/src/composables/useGoogleSignIn.ts rename to frontend/src/composables/useGoogleSignIn.ts diff --git a/frontend-vue/src/composables/useNotify.ts b/frontend/src/composables/useNotify.ts similarity index 100% rename from frontend-vue/src/composables/useNotify.ts rename to frontend/src/composables/useNotify.ts diff --git a/frontend-vue/src/composables/useRowEdit.spec.ts b/frontend/src/composables/useRowEdit.spec.ts similarity index 100% rename from frontend-vue/src/composables/useRowEdit.spec.ts rename to frontend/src/composables/useRowEdit.spec.ts diff --git a/frontend-vue/src/composables/useRowEdit.ts b/frontend/src/composables/useRowEdit.ts similarity index 100% rename from frontend-vue/src/composables/useRowEdit.ts rename to frontend/src/composables/useRowEdit.ts diff --git a/frontend-vue/src/composables/useRowSelection.spec.ts b/frontend/src/composables/useRowSelection.spec.ts similarity index 100% rename from frontend-vue/src/composables/useRowSelection.spec.ts rename to frontend/src/composables/useRowSelection.spec.ts diff --git a/frontend-vue/src/composables/useRowSelection.ts b/frontend/src/composables/useRowSelection.ts similarity index 100% rename from frontend-vue/src/composables/useRowSelection.ts rename to frontend/src/composables/useRowSelection.ts diff --git a/frontend-vue/src/composables/useRowView.spec.ts b/frontend/src/composables/useRowView.spec.ts similarity index 100% rename from frontend-vue/src/composables/useRowView.spec.ts rename to frontend/src/composables/useRowView.spec.ts diff --git a/frontend-vue/src/composables/useRowView.ts b/frontend/src/composables/useRowView.ts similarity index 100% rename from frontend-vue/src/composables/useRowView.ts rename to frontend/src/composables/useRowView.ts diff --git a/frontend-vue/src/composables/useTableData.spec.ts b/frontend/src/composables/useTableData.spec.ts similarity index 100% rename from frontend-vue/src/composables/useTableData.spec.ts rename to frontend/src/composables/useTableData.spec.ts diff --git a/frontend-vue/src/composables/useTableData.ts b/frontend/src/composables/useTableData.ts similarity index 100% rename from frontend-vue/src/composables/useTableData.ts rename to frontend/src/composables/useTableData.ts diff --git a/frontend-vue/src/composables/useTableFolders.spec.ts b/frontend/src/composables/useTableFolders.spec.ts similarity index 100% rename from frontend-vue/src/composables/useTableFolders.spec.ts rename to frontend/src/composables/useTableFolders.spec.ts diff --git a/frontend-vue/src/composables/useTableFolders.ts b/frontend/src/composables/useTableFolders.ts similarity index 100% rename from frontend-vue/src/composables/useTableFolders.ts rename to frontend/src/composables/useTableFolders.ts diff --git a/frontend-vue/src/composables/useTableRules.spec.ts b/frontend/src/composables/useTableRules.spec.ts similarity index 100% rename from frontend-vue/src/composables/useTableRules.spec.ts rename to frontend/src/composables/useTableRules.spec.ts diff --git a/frontend-vue/src/composables/useTableRules.ts b/frontend/src/composables/useTableRules.ts similarity index 100% rename from frontend-vue/src/composables/useTableRules.ts rename to frontend/src/composables/useTableRules.ts diff --git a/frontend/src/config.json b/frontend/src/config.json deleted file mode 100644 index 343eddd45..000000000 --- a/frontend/src/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "#important to keep it /api for prod": true, - "baseURL": "/api", - "saasURL": "" -} diff --git a/frontend-vue/src/config.spec.ts b/frontend/src/config.spec.ts similarity index 100% rename from frontend-vue/src/config.spec.ts rename to frontend/src/config.spec.ts diff --git a/frontend-vue/src/config.ts b/frontend/src/config.ts similarity index 100% rename from frontend-vue/src/config.ts rename to frontend/src/config.ts diff --git a/frontend-vue/src/consts/acronyms.ts b/frontend/src/consts/acronyms.ts similarity index 100% rename from frontend-vue/src/consts/acronyms.ts rename to frontend/src/consts/acronyms.ts diff --git a/frontend-vue/src/consts/code-snippets.ts b/frontend/src/consts/code-snippets.ts similarity index 100% rename from frontend-vue/src/consts/code-snippets.ts rename to frontend/src/consts/code-snippets.ts diff --git a/frontend-vue/src/consts/countries.ts b/frontend/src/consts/countries.ts similarity index 100% rename from frontend-vue/src/consts/countries.ts rename to frontend/src/consts/countries.ts diff --git a/frontend-vue/src/consts/currencies.ts b/frontend/src/consts/currencies.ts similarity index 100% rename from frontend-vue/src/consts/currencies.ts rename to frontend/src/consts/currencies.ts diff --git a/frontend-vue/src/consts/databases.ts b/frontend/src/consts/databases.ts similarity index 100% rename from frontend-vue/src/consts/databases.ts rename to frontend/src/consts/databases.ts diff --git a/frontend-vue/src/consts/filter-types.ts b/frontend/src/consts/filter-types.ts similarity index 100% rename from frontend-vue/src/consts/filter-types.ts rename to frontend/src/consts/filter-types.ts diff --git a/frontend-vue/src/consts/google-IP-addresses.ts b/frontend/src/consts/google-IP-addresses.ts similarity index 100% rename from frontend-vue/src/consts/google-IP-addresses.ts rename to frontend/src/consts/google-IP-addresses.ts diff --git a/frontend-vue/src/consts/languages.ts b/frontend/src/consts/languages.ts similarity index 100% rename from frontend-vue/src/consts/languages.ts rename to frontend/src/consts/languages.ts diff --git a/frontend-vue/src/consts/record-edit-types.ts b/frontend/src/consts/record-edit-types.ts similarity index 100% rename from frontend-vue/src/consts/record-edit-types.ts rename to frontend/src/consts/record-edit-types.ts diff --git a/frontend-vue/src/consts/record-view-types.ts b/frontend/src/consts/record-view-types.ts similarity index 100% rename from frontend-vue/src/consts/record-view-types.ts rename to frontend/src/consts/record-view-types.ts diff --git a/frontend-vue/src/consts/table-display-types.ts b/frontend/src/consts/table-display-types.ts similarity index 100% rename from frontend-vue/src/consts/table-display-types.ts rename to frontend/src/consts/table-display-types.ts diff --git a/frontend/src/consts/widget-types.angular.json b/frontend/src/consts/widget-types.angular.json new file mode 100644 index 000000000..0f9e85b28 --- /dev/null +++ b/frontend/src/consts/widget-types.angular.json @@ -0,0 +1,902 @@ +{ + "record-edit-types.ts": { + "UIwidgets": { + "Default": "", + "Boolean": "BooleanEdit", + "Code": "CodeEdit", + "Color": "ColorEdit", + "Country": "CountryEdit", + "Date": "DateEdit", + "DateTime": "DateTimeEdit", + "Binary": "BinaryEdit", + "File": "FileEdit", + "Foreign_key": "ForeignKeyEdit", + "Image": "ImageEdit", + "JSON": "JsonEditorEdit", + "Language": "LanguageEdit", + "Markdown": "MarkdownEdit", + "Money": "MoneyEdit", + "Number": "NumberEdit", + "Password": "PasswordEdit", + "Phone": "PhoneEdit", + "Range": "RangeEdit", + "Readonly": "StaticTextEdit", + "Select": "SelectEdit", + "String": "TextEdit", + "Textarea": "LongTextEdit", + "Time": "TimeEdit", + "Timezone": "TimezoneEdit", + "URL": "UrlEdit", + "UUID": "UuidEdit", + "S3": "S3Edit", + "Email": "TextEdit" + }, + "recordEditTypes": { + "postgres": { + "real": "NumberEdit", + "double precision": "NumberEdit", + "smallint": "NumberEdit", + "integer": "NumberEdit", + "bigint": "NumberEdit", + "numeric": "NumberEdit", + "boolean": "BooleanEdit", + "timestamp without time zone": "DateTimeEdit", + "timestamp with time zone": "DateTimeEdit", + "time without time zone": "TimeEdit", + "time with time zone": "TimeEdit", + "date": "DateEdit", + "abstime": "DateTimeEdit", + "realtime": "DateTimeEdit", + "interval": "TimeIntervalEdit", + "character varying": "TextEdit", + "macaddr": "TextEdit", + "macaddr8": "TextEdit", + "cidr": "TextEdit", + "inet": "TextEdit", + "uuid": "UuidEdit", + "text": "LongTextEdit", + "xml": "LongTextEdit", + "enum": "SelectEdit", + "json": "JsonEditorEdit", + "jsonb": "JsonEditorEdit", + "ARRAY": "JsonEditorEdit", + "bytea": "BinaryEdit", + "money": "MoneyEdit", + "point": "PointEdit", + "line": "TextEdit", + "circle": "TextEdit", + "path": "TextEdit", + "box": "TextEdit", + "lseg": "TextEdit", + "foreign key": "ForeignKeyEdit" + }, + "mysql": { + "tinyint": "NumberEdit", + "smallint": "NumberEdit", + "mediumint": "NumberEdit", + "int": "NumberEdit", + "bigint": "NumberEdit", + "decimal": "NumberEdit", + "float": "NumberEdit", + "double": "NumberEdit", + "year": "NumberEdit", + "boolean": "BooleanEdit", + "date": "DateEdit", + "time": "TimeEdit", + "datetime": "DateTimeEdit", + "timestamp": "DateTimeEdit", + "char": "TextEdit", + "varchar": "TextEdit", + "text": "LongTextEdit", + "tinytext": "LongTextEdit", + "mediumtext": "LongTextEdit", + "longtext": "LongTextEdit", + "json": "JsonEditorEdit", + "enum": "SelectEdit", + "binary": "BinaryEdit", + "varbinary": "BinaryEdit", + "blob": "BinaryEdit", + "tinyblob": "BinaryEdit", + "mediumblob": "BinaryEdit", + "longblob": "BinaryEdit", + "set": "TextEdit", + "foreign key": "ForeignKeyEdit" + }, + "oracledb": { + "NUMBER": "NumberEdit", + "FLOAT": "NumberEdit", + "BINARY_FLOAT": "NumberEdit", + "BINARY_DOUBLE": "NumberEdit", + "INTERVAL YEAR": "NumberEdit", + "INTERVAL DAY": "NumberEdit", + "DATE": "DateEdit", + "TIMESTAMP": "DateTimeEdit", + "CHAR": "TextEdit", + "NCHAR": "TextEdit", + "CLOB": "TextEdit", + "NCLOB": "TextEdit", + "VARCHAR2": "TextEdit", + "VARCHAR": "TextEdit", + "NVARCHAR2": "TextEdit", + "BLOB": "BinaryEdit", + "BFILE": "BinaryEdit", + "RAW": "BinaryEdit", + "LONG RAW": "BinaryEdit", + "LONG": "BinaryEdit", + "foreign key": "ForeignKeyEdit" + }, + "mssql": { + "bigint": "NumberEdit", + "int": "NumberEdit", + "smallint": "NumberEdit", + "tinyint": "NumberEdit", + "decimal": "NumberEdit", + "bitdecimal": "NumberEdit", + "numeric": "NumberEdit", + "real": "NumberEdit", + "uniqueidentifier": "UuidEdit", + "char": "TextEdit", + "varchar": "TextEdit", + "text": "LongTextEdit", + "nchar": "LongTextEdit", + "nvarchar": "LongTextEdit", + "ntext": "LongTextEdit", + "date": "DateEdit", + "datetime": "DateTimeEdit", + "smalldatetime": "DateTimeEdit", + "timestamp": "DateTimeEdit", + "binary": "BinaryEdit", + "varbinary": "BinaryEdit", + "image": "BinaryEdit", + "money": "MoneyEdit", + "smallmoney": "MoneyEdit", + "foreign key": "ForeignKeyEdit" + }, + "mongodb": { + "number": "NumberEdit", + "double": "NumberEdit", + "int32": "NumberEdit", + "long": "NumberEdit", + "decimal128": "NumberEdit", + "boolean": "BooleanEdit", + "date": "DateEdit", + "timestamp": "DateTimeEdit", + "string": "TextEdit", + "regexp": "TextEdit", + "objectid": "TextEdit", + "binary": "BinaryEdit", + "object": "JsonEditorEdit", + "array": "JsonEditorEdit", + "unknown": "TextEdit", + "foreign key": "ForeignKeyEdit" + }, + "dynamodb": { + "string": "TextEdit", + "number": "NumberEdit", + "boolean": "BooleanEdit", + "null": "StaticTextEdit", + "array": "JsonEditorEdit", + "json": "JsonEditorEdit", + "binary": "BinaryEdit" + }, + "cassandra": { + "int": "NumberEdit", + "bigint": "NumberEdit", + "varint": "NumberEdit", + "decimal": "NumberEdit", + "float": "NumberEdit", + "double": "NumberEdit", + "boolean": "BooleanEdit", + "timeuuid": "IdEdit", + "timestamp": "DateTimeEdit", + "date": "DateEdit", + "time": "TimeEdit", + "uuid": "UuidEdit", + "varchar": "TextEdit", + "inet": "TextEdit", + "ascii": "TextEdit", + "text": "TextEdit", + "blob": "BinaryEdit", + "list": "JsonEditorEdit", + "map": "JsonEditorEdit", + "set": "JsonEditorEdit" + }, + "redis": { + "string": "TextEdit", + "integer": "NumberEdit", + "decimal": "NumberEdit", + "boolean": "BooleanEdit", + "array": "JsonEditorEdit", + "json": "JsonEditorEdit" + }, + "elasticsearch": { + "string": "TextEdit", + "number": "NumberEdit", + "boolean": "BooleanEdit", + "date": "DateEdit", + "object": "JsonEditorEdit", + "array": "JsonEditorEdit", + "binary": "BinaryEdit" + }, + "clickhouse": { + "string": "TextEdit", + "uuid": "UuidEdit", + "boolean": "BooleanEdit", + "integer": "NumberEdit", + "bigint": "NumberEdit", + "float": "NumberEdit", + "double": "NumberEdit", + "decimal": "NumberEdit", + "date": "DateEdit", + "datetime": "DateTimeEdit", + "json": "JsonEditorEdit", + "object": "JsonEditorEdit", + "array": "JsonEditorEdit" + } + } + }, + "record-view-types.ts": { + "UIwidgets": { + "Default": "", + "Boolean": "BooleanRecordView", + "Date": "DateRecordView", + "Time": "TimeRecordView", + "DateTime": "DateTimeRecordView", + "JSON": "JsonEditorRecordView", + "Language": "LanguageRecordView", + "Markdown": "MarkdownRecordView", + "Textarea": "LongTextRecordView", + "String": "TextRecordView", + "Readonly": "StaticTextRecordView", + "Number": "NumberRecordView", + "Select": "SelectRecordView", + "Password": "PasswordRecordView", + "Binary": "BinaryRecordView", + "File": "FileRecordView", + "Code": "CodeRecordView", + "Image": "ImageRecordView", + "URL": "UrlRecordView", + "Country": "CountryRecordView", + "Phone": "PhoneRecordView", + "Money": "MoneyRecordView", + "Foreign_key": "ForeignKeyRecordView", + "Color": "ColorRecordView", + "UUID": "UuidRecordView", + "Range": "RangeRecordView", + "Timezone": "TimezoneRecordView", + "S3": "S3RecordView" + }, + "recordViewFieldTypes": { + "postgres": { + "real": "NumberRecordView", + "double precision": "NumberRecordView", + "smallint": "NumberRecordView", + "integer": "NumberRecordView", + "bigint": "NumberRecordView", + "numeric": "NumberRecordView", + "boolean": "BooleanRecordView", + "timestamp without time zone": "DateTimeRecordView", + "timestamp with time zone": "DateTimeRecordView", + "time without time zone": "TimeRecordView", + "time with time zone": "TimeRecordView", + "date": "DateRecordView", + "abstime": "DateTimeRecordView", + "realtime": "DateTimeRecordView", + "interval": "TimeIntervalRecordView", + "character varying": "TextRecordView", + "macaddr": "TextRecordView", + "macaddr8": "TextRecordView", + "cidr": "TextRecordView", + "inet": "TextRecordView", + "uuid": "UuidRecordView", + "text": "LongTextRecordView", + "xml": "LongTextRecordView", + "enum": "SelectRecordView", + "json": "JsonEditorRecordView", + "jsonb": "JsonEditorRecordView", + "ARRAY": "JsonEditorRecordView", + "bytea": "BinaryRecordView", + "money": "MoneyRecordView", + "point": "PointRecordView", + "line": "TextRecordView", + "circle": "TextRecordView", + "path": "TextRecordView", + "box": "TextRecordView", + "lseg": "TextRecordView", + "foreign key": "ForeignKeyRecordView" + }, + "mysql": { + "tinyint": "NumberRecordView", + "smallint": "NumberRecordView", + "mediumint": "NumberRecordView", + "int": "NumberRecordView", + "bigint": "NumberRecordView", + "decimal": "NumberRecordView", + "float": "NumberRecordView", + "double": "NumberRecordView", + "year": "NumberRecordView", + "boolean": "BooleanRecordView", + "date": "DateRecordView", + "time": "TimeRecordView", + "datetime": "DateTimeRecordView", + "timestamp": "DateTimeRecordView", + "char": "TextRecordView", + "varchar": "TextRecordView", + "text": "LongTextRecordView", + "tinytext": "LongTextRecordView", + "mediumtext": "LongTextRecordView", + "longtext": "LongTextRecordView", + "json": "JsonEditorRecordView", + "enum": "SelectRecordView", + "binary": "BinaryRecordView", + "varbinary": "BinaryRecordView", + "blob": "BinaryRecordView", + "tinyblob": "BinaryRecordView", + "mediumblob": "BinaryRecordView", + "longblob": "BinaryRecordView", + "set": "TextRecordView", + "foreign key": "ForeignKeyRecordView" + }, + "oracledb": { + "NUMBER": "NumberRecordView", + "FLOAT": "NumberRecordView", + "BINARY_FLOAT": "NumberRecordView", + "BINARY_DOUBLE": "NumberRecordView", + "INTERVAL YEAR": "NumberRecordView", + "INTERVAL DAY": "NumberRecordView", + "DATE": "DateRecordView", + "TIMESTAMP": "DateTimeRecordView", + "CHAR": "TextRecordView", + "NCHAR": "TextRecordView", + "CLOB": "TextRecordView", + "NCLOB": "TextRecordView", + "VARCHAR2": "TextRecordView", + "VARCHAR": "TextRecordView", + "NVARCHAR2": "TextRecordView", + "BLOB": "BinaryRecordView", + "BFILE": "BinaryRecordView", + "RAW": "BinaryRecordView", + "LONG RAW": "BinaryRecordView", + "LONG": "BinaryRecordView", + "foreign key": "ForeignKeyRecordView" + }, + "mssql": { + "bigint": "NumberRecordView", + "int": "NumberRecordView", + "smallint": "NumberRecordView", + "tinyint": "NumberRecordView", + "decimal": "NumberRecordView", + "bitdecimal": "NumberRecordView", + "numeric": "NumberRecordView", + "real": "NumberRecordView", + "uniqueidentifier": "UuidRecordView", + "char": "TextRecordView", + "varchar": "TextRecordView", + "text": "LongTextRecordView", + "nchar": "LongTextRecordView", + "nvarchar": "LongTextRecordView", + "ntext": "LongTextRecordView", + "date": "DateRecordView", + "datetime": "DateTimeRecordView", + "smalldatetime": "DateTimeRecordView", + "timestamp": "DateTimeRecordView", + "binary": "BinaryRecordView", + "varbinary": "BinaryRecordView", + "image": "ImageRecordView", + "money": "MoneyRecordView", + "smallmoney": "MoneyRecordView", + "foreign key": "ForeignKeyRecordView" + }, + "mongodb": { + "number": "NumberRecordView", + "double": "NumberRecordView", + "int32": "NumberRecordView", + "long": "NumberRecordView", + "decimal128": "NumberRecordView", + "boolean": "BooleanRecordView", + "date": "DateRecordView", + "timestamp": "DateTimeRecordView", + "string": "TextRecordView", + "regexp": "TextRecordView", + "objectid": "TextRecordView", + "binary": "BinaryRecordView", + "object": "JsonEditorRecordView", + "array": "JsonEditorRecordView", + "unknown": "TextRecordView", + "foreign key": "ForeignKeyRecordView" + }, + "dynamodb": { + "string": "TextRecordView", + "number": "NumberRecordView", + "boolean": "BooleanRecordView", + "null": "StaticTextRecordView", + "array": "JsonEditorRecordView", + "json": "JsonEditorRecordView", + "binary": "BinaryRecordView" + }, + "cassandra": { + "int": "NumberRecordView", + "bigint": "NumberRecordView", + "varint": "NumberRecordView", + "decimal": "NumberRecordView", + "float": "NumberRecordView", + "double": "NumberRecordView", + "boolean": "BooleanRecordView", + "timeuuid": "IdRecordView", + "timestamp": "DateTimeRecordView", + "date": "DateRecordView", + "time": "TimeRecordView", + "uuid": "UuidRecordView", + "varchar": "TextRecordView", + "inet": "TextRecordView", + "ascii": "TextRecordView", + "text": "LongTextRecordView", + "blob": "BinaryRecordView", + "list": "JsonEditorRecordView", + "map": "JsonEditorRecordView", + "set": "JsonEditorRecordView" + }, + "redis": { + "string": "TextRecordView", + "integer": "NumberRecordView", + "decimal": "NumberRecordView", + "boolean": "BooleanRecordView", + "array": "JsonEditorRecordView", + "json": "JsonEditorRecordView" + }, + "elasticsearch": { + "string": "TextRecordView", + "number": "NumberRecordView", + "boolean": "BooleanRecordView", + "date": "DateRecordView", + "object": "JsonEditorRecordView", + "array": "JsonEditorRecordView", + "binary": "BinaryRecordView" + }, + "clickhouse": { + "string": "TextRecordView", + "uuid": "UuidRecordView", + "boolean": "BooleanRecordView", + "integer": "NumberRecordView", + "bigint": "NumberRecordView", + "float": "NumberRecordView", + "double": "NumberRecordView", + "decimal": "NumberRecordView", + "date": "DateRecordView", + "datetime": "DateTimeRecordView", + "json": "JsonEditorRecordView", + "object": "JsonEditorRecordView", + "array": "JsonEditorRecordView" + } + } + }, + "table-display-types.ts": { + "UIwidgets": { + "Default": "", + "Boolean": "BooleanDisplay", + "Code": "CodeDisplay", + "Color": "ColorDisplay", + "Country": "CountryDisplay", + "Date": "DateDisplay", + "DateTime": "DateTimeDisplay", + "Binary": "BinaryDisplay", + "File": "FileDisplay", + "Foreign_key": "ForeignKeyDisplay", + "Image": "ImageDisplay", + "JSON": "JsonEditorDisplay", + "Language": "LanguageDisplay", + "Markdown": "MarkdownDisplay", + "Money": "MoneyDisplay", + "Number": "NumberDisplay", + "Password": "PasswordDisplay", + "Phone": "PhoneDisplay", + "Range": "RangeDisplay", + "Readonly": "StaticTextDisplay", + "Select": "SelectDisplay", + "String": "TextDisplay", + "Textarea": "LongTextDisplay", + "Time": "TimeDisplay", + "Timezone": "TimezoneDisplay", + "URL": "UrlDisplay", + "UUID": "UuidDisplay", + "S3": "S3Display", + "Email": "TextDisplay" + }, + "tableDisplayTypes": { + "postgres": { + "real": "NumberDisplay", + "double precision": "NumberDisplay", + "smallint": "NumberDisplay", + "integer": "NumberDisplay", + "bigint": "NumberDisplay", + "numeric": "NumberDisplay", + "boolean": "BooleanDisplay", + "timestamp without time zone": "DateTimeDisplay", + "timestamp with time zone": "DateTimeDisplay", + "time without time zone": "TimeDisplay", + "time with time zone": "TimeDisplay", + "date": "DateDisplay", + "abstime": "DateTimeDisplay", + "realtime": "DateTimeDisplay", + "interval": "TimeIntervalDisplay", + "character varying": "TextDisplay", + "macaddr": "TextDisplay", + "macaddr8": "TextDisplay", + "cidr": "TextDisplay", + "inet": "TextDisplay", + "uuid": "UuidDisplay", + "text": "LongTextDisplay", + "xml": "LongTextDisplay", + "enum": "SelectDisplay", + "json": "JsonEditorDisplay", + "jsonb": "JsonEditorDisplay", + "ARRAY": "JsonEditorDisplay", + "bytea": "BinaryDisplay", + "money": "MoneyDisplay", + "point": "PointDisplay", + "line": "TextDisplay", + "circle": "TextDisplay", + "path": "TextDisplay", + "box": "TextDisplay", + "lseg": "TextDisplay", + "foreign key": "ForeignKeyDisplay" + }, + "mysql": { + "tinyint": "NumberDisplay", + "smallint": "NumberDisplay", + "mediumint": "NumberDisplay", + "int": "NumberDisplay", + "bigint": "NumberDisplay", + "decimal": "NumberDisplay", + "float": "NumberDisplay", + "double": "NumberDisplay", + "year": "NumberDisplay", + "boolean": "BooleanDisplay", + "date": "DateDisplay", + "time": "TimeDisplay", + "datetime": "DateTimeDisplay", + "timestamp": "DateTimeDisplay", + "char": "TextDisplay", + "varchar": "TextDisplay", + "text": "LongTextDisplay", + "tinytext": "LongTextDisplay", + "mediumtext": "LongTextDisplay", + "longtext": "LongTextDisplay", + "json": "JsonEditorDisplay", + "enum": "SelectDisplay", + "binary": "BinaryDisplay", + "varbinary": "BinaryDisplay", + "blob": "BinaryDisplay", + "tinyblob": "BinaryDisplay", + "mediumblob": "BinaryDisplay", + "longblob": "BinaryDisplay", + "set": "TextDisplay", + "foreign key": "ForeignKeyDisplay" + }, + "oracledb": { + "NUMBER": "NumberDisplay", + "FLOAT": "NumberDisplay", + "BINARY_FLOAT": "NumberDisplay", + "BINARY_DOUBLE": "NumberDisplay", + "INTERVAL YEAR": "NumberDisplay", + "INTERVAL DAY": "NumberDisplay", + "DATE": "DateDisplay", + "TIMESTAMP": "DateTimeDisplay", + "CHAR": "TextDisplay", + "NCHAR": "TextDisplay", + "CLOB": "TextDisplay", + "NCLOB": "TextDisplay", + "VARCHAR2": "TextDisplay", + "VARCHAR": "TextDisplay", + "NVARCHAR2": "TextDisplay", + "BLOB": "BinaryDisplay", + "BFILE": "BinaryDisplay", + "RAW": "BinaryDisplay", + "LONG RAW": "BinaryDisplay", + "LONG": "BinaryDisplay", + "foreign key": "ForeignKeyDisplay" + }, + "mssql": { + "bigint": "NumberDisplay", + "int": "NumberDisplay", + "smallint": "NumberDisplay", + "tinyint": "NumberDisplay", + "decimal": "NumberDisplay", + "bitdecimal": "NumberDisplay", + "numeric": "NumberDisplay", + "real": "NumberDisplay", + "uniqueidentifier": "UuidDisplay", + "char": "TextDisplay", + "varchar": "TextDisplay", + "text": "LongTextDisplay", + "nchar": "LongTextDisplay", + "nvarchar": "LongTextDisplay", + "ntext": "LongTextDisplay", + "date": "DateDisplay", + "datetime": "DateTimeDisplay", + "smalldatetime": "DateTimeDisplay", + "timestamp": "DateTimeDisplay", + "binary": "BinaryDisplay", + "varbinary": "BinaryDisplay", + "image": "ImageDisplay", + "money": "MoneyDisplay", + "smallmoney": "MoneyDisplay", + "foreign key": "ForeignKeyDisplay" + }, + "mongodb": { + "number": "NumberDisplay", + "double": "NumberDisplay", + "int32": "NumberDisplay", + "long": "NumberDisplay", + "decimal128": "NumberDisplay", + "boolean": "BooleanDisplay", + "date": "DateDisplay", + "timestamp": "DateTimeDisplay", + "string": "TextDisplay", + "regexp": "TextDisplay", + "objectid": "TextDisplay", + "binary": "BinaryDisplay", + "object": "JsonEditorDisplay", + "array": "JsonEditorDisplay", + "unknown": "TextDisplay", + "foreign key": "ForeignKeyDisplay" + }, + "dynamodb": { + "string": "TextDisplay", + "number": "NumberDisplay", + "boolean": "BooleanDisplay", + "null": "StaticTextDisplay", + "array": "JsonEditorDisplay", + "json": "JsonEditorDisplay", + "binary": "BinaryDisplay" + }, + "cassandra": { + "int": "NumberDisplay", + "bigint": "NumberDisplay", + "varint": "NumberDisplay", + "decimal": "NumberDisplay", + "float": "NumberDisplay", + "double": "NumberDisplay", + "boolean": "BooleanDisplay", + "timeuuid": "IdDisplay", + "timestamp": "DateTimeDisplay", + "date": "DateDisplay", + "time": "TimeDisplay", + "uuid": "UuidDisplay", + "varchar": "TextDisplay", + "inet": "TextDisplay", + "ascii": "TextDisplay", + "text": "LongTextDisplay", + "blob": "BinaryDisplay", + "list": "JsonEditorDisplay", + "map": "JsonEditorDisplay", + "set": "JsonEditorDisplay" + }, + "redis": { + "string": "TextDisplay", + "integer": "NumberDisplay", + "decimal": "NumberDisplay", + "boolean": "BooleanDisplay", + "array": "JsonEditorDisplay", + "json": "JsonEditorDisplay" + }, + "elasticsearch": { + "string": "TextDisplay", + "number": "NumberDisplay", + "boolean": "BooleanDisplay", + "date": "DateDisplay", + "object": "JsonEditorDisplay", + "array": "JsonEditorDisplay", + "binary": "BinaryDisplay" + }, + "clickhouse": { + "string": "TextDisplay", + "uuid": "UuidDisplay", + "boolean": "BooleanDisplay", + "integer": "NumberDisplay", + "bigint": "NumberDisplay", + "float": "NumberDisplay", + "double": "NumberDisplay", + "decimal": "NumberDisplay", + "date": "DateDisplay", + "datetime": "DateTimeDisplay", + "json": "JsonEditorDisplay", + "object": "JsonEditorDisplay", + "array": "JsonEditorDisplay" + } + } + }, + "filter-types.ts": { + "UIwidgets": { + "Default": "", + "Boolean": "BooleanFilter", + "Country": "CountryFilter", + "Date": "DateFilter", + "DateTime": "DateTimeFilter", + "Binary": "BinaryFilter", + "File": "FileFilter", + "Foreign_key": "ForeignKeyFilter", + "JSON": "JsonEditorFilter", + "Number": "NumberFilter", + "Password": "PasswordFilter", + "Readonly": "StaticTextFilter", + "Select": "SelectFilter", + "String": "TextFilter", + "Textarea": "LongTextFilter", + "Time": "TimeFilter", + "TimeInterval": "TimeIntervalFilter", + "Timezone": "TimezoneFilter", + "Point": "PointFilter", + "ID": "IdFilter", + "Phone": "PhoneFilter", + "Email": "EmailFilter" + }, + "filterTypes": { + "postgres": { + "real": "NumberFilter", + "double precision": "NumberFilter", + "smallint": "NumberFilter", + "integer": "NumberFilter", + "bigint": "NumberFilter", + "numeric": "NumberFilter", + "boolean": "BooleanFilter", + "timestamp without time zone": "DateTimeFilter", + "timestamp with time zone": "DateTimeFilter", + "date": "DateFilter", + "abstime": "DateTimeFilter", + "realtime": "DateTimeFilter", + "interval": "TimeIntervalFilter", + "character varying": "TextFilter", + "macaddr": "TextFilter", + "macaddr8": "TextFilter", + "cidr": "TextFilter", + "inet": "TextFilter", + "uuid": "TextFilter", + "text": "LongTextFilter", + "xml": "LongTextFilter", + "enum": "SelectFilter", + "json": "JsonEditorFilter", + "jsonb": "JsonEditorFilter", + "bytea": "BinaryFilter", + "money": "TextFilter", + "point": "PointFilter", + "line": "TextFilter", + "circle": "TextFilter", + "path": "TextFilter", + "box": "TextFilter", + "lseg": "TextFilter", + "foreign key": "ForeignKeyFilter" + }, + "mysql": { + "tinyint": "NumberFilter", + "smallint": "NumberFilter", + "mediumint": "NumberFilter", + "int": "NumberFilter", + "bigint": "NumberFilter", + "decimal": "NumberFilter", + "float": "NumberFilter", + "double": "NumberFilter", + "year": "NumberFilter", + "boolean": "BooleanFilter", + "date": "DateFilter", + "time": "TimeFilter", + "datetime": "DateTimeFilter", + "timestamp": "DateTimeFilter", + "char": "TextFilter", + "varchar": "TextFilter", + "text": "LongTextFilter", + "tinytext": "LongTextFilter", + "mediumtext": "LongTextFilter", + "longtext": "LongTextFilter", + "json": "JsonEditorFilter", + "enum": "SelectFilter", + "binary": "BinaryFilter", + "varbinary": "BinaryFilter", + "blob": "BinaryFilter", + "tinyblob": "BinaryFilter", + "mediumblob": "BinaryFilter", + "longblob": "BinaryFilter", + "set": "TextFilter", + "foreign key": "ForeignKeyFilter" + }, + "oracledb": { + "NUMBER": "NumberFilter", + "FLOAT": "NumberFilter", + "BINARY_FLOAT": "NumberFilter", + "BINARY_DOUBLE": "NumberFilter", + "INTERVAL YEAR": "NumberFilter", + "INTERVAL DAY": "NumberFilter", + "DATE": "DateFilter", + "TIMESTAMP": "DateTimeFilter", + "CHAR": "TextFilter", + "NCHAR": "TextFilter", + "CLOB": "TextFilter", + "NCLOB": "TextFilter", + "VARCHAR2": "TextFilter", + "VARCHAR": "TextFilter", + "NVARCHAR2": "TextFilter", + "BLOB": "BinaryFilter", + "BFILE": "BinaryFilter", + "RAW": "BinaryFilter", + "LONG RAW": "BinaryFilter", + "LONG": "BinaryFilter", + "foreign key": "ForeignKeyFilter" + }, + "mssql": { + "bigint": "NumberFilter", + "int": "NumberFilter", + "smallint": "NumberFilter", + "tinyint": "NumberFilter", + "decimal": "NumberFilter", + "bitdecimal": "NumberFilter", + "numeric": "NumberFilter", + "real": "NumberFilter", + "uniqueidentifier": "IdFilter", + "char": "TextFilter", + "varchar": "TextFilter", + "text": "LongTextFilter", + "nchar": "LongTextFilter", + "nvarchar": "LongTextFilter", + "ntext": "LongTextFilter", + "date": "DateFilter", + "datetime": "DateTimeFilter", + "smalldatetime": "DateTimeFilter", + "timestamp": "DateTimeFilter", + "binary": "BinaryFilter", + "varbinary": "BinaryFilter", + "image": "BinaryFilter", + "money": "TextFilter", + "smallmoney": "TextFilter", + "foreign key": "ForeignKeyFilter" + }, + "mongodb": { + "number": "NumberFilter", + "double": "NumberFilter", + "int32": "NumberFilter", + "long": "NumberFilter", + "decimal128": "NumberFilter", + "boolean": "BooleanFilter", + "date": "DateFilter", + "timestamp": "DateTimeFilter", + "string": "TextFilter", + "regexp": "TextFilter", + "objectid": "TextFilter", + "binary": "BinaryFilter", + "object": "JsonEditorFilter", + "array": "JsonEditorFilter", + "foreign key": "ForeignKeyFilter" + }, + "dynamodb": { + "string": "TextFilter", + "number": "NumberFilter", + "boolean": "BooleanFilter", + "null": "StaticTextFilter", + "array": "JsonEditorFilter", + "json": "JsonEditorFilter", + "binary": "BinaryFilter" + }, + "cassandra": { + "int": "NumberFilter", + "bigint": "NumberFilter", + "varint": "NumberFilter", + "decimal": "NumberFilter", + "float": "NumberFilter", + "double": "NumberFilter", + "boolean": "BooleanFilter", + "timeuuid": "IdFilter", + "timestamp": "DateTimeFilter", + "date": "DateFilter", + "time": "TimeFilter", + "uuid": "TextFilter", + "varchar": "TextFilter", + "inet": "TextFilter", + "ascii": "TextFilter", + "text": "TextFilter", + "blob": "BinaryFilter", + "list": "JsonEditorFilter", + "map": "JsonEditorFilter", + "set": "JsonEditorFilter" + } + } + } +} diff --git a/frontend-vue/src/consts/widget-types.spec.ts b/frontend/src/consts/widget-types.spec.ts similarity index 69% rename from frontend-vue/src/consts/widget-types.spec.ts rename to frontend/src/consts/widget-types.spec.ts index 68afcc33d..1b3f7cf64 100644 --- a/frontend-vue/src/consts/widget-types.spec.ts +++ b/frontend/src/consts/widget-types.spec.ts @@ -1,43 +1,22 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import * as filterTypes from './filter-types'; import * as recordEditTypes from './record-edit-types'; import * as recordViewTypes from './record-view-types'; import * as tableDisplayTypes from './table-display-types'; +import ANGULAR_MAPS from './widget-types.angular.json'; /** * Parity guard for the widget type maps: every DB-type → widget table must keep exactly the keys * (and the same widget per key) as the Angular map it was ported from, because the row edit form, * the record view, the table cells and the filters all pick their widget from these tables. * - * The Angular source is parsed textually rather than imported (it pulls in Angular components). - * Until the Angular app is deleted (Phase 4) this pins the two together. + * widget-types.angular.json is a snapshot of the Angular frontend/src/app/consts/*-types.ts maps + * (component names without the `Component` suffix), taken before the Angular app was deleted. */ -const ANGULAR_CONSTS = resolve(process.cwd(), '../frontend/src/app/consts'); - type WidgetMap = Record; -/** `{ section: { key: 'ComponentName' } }` from an Angular `export const = { … }` block. */ -function angularMap(file: string, exportName: string): Record> | Record { - const source = readFileSync(resolve(ANGULAR_CONSTS, file), 'utf8'); - const start = source.indexOf(`export const ${exportName} = {`); - if (start < 0) throw new Error(`${exportName} not found in ${file}`); - let depth = 0; - let end = start; - for (let index = source.indexOf('{', start); index < source.length; index++) { - if (source[index] === '{') depth++; - else if (source[index] === '}' && --depth === 0) { - end = index + 1; - break; - } - } - const body = source - .slice(source.indexOf('{', start), end) - .replace(/\/\/.*$/gm, '') - .replace(/\b([A-Z]\w*)Component\b/g, '"$1"'); - // Object literal with quoted/unquoted keys and string/component values → evaluate as JS. - return new Function(`return (${body});`)() as Record>; +function angularMap(file: string, exportName: string): unknown { + return (ANGULAR_MAPS as Record>)[file]?.[exportName]; } type AsyncWidget = { __asyncLoader: () => Promise<{ __name?: string } | { default: { __name?: string } }> }; diff --git a/frontend/src/custom-theme.scss b/frontend/src/custom-theme.scss deleted file mode 100644 index 769da09d0..000000000 --- a/frontend/src/custom-theme.scss +++ /dev/null @@ -1,192 +0,0 @@ -@use '@angular/material' as mat; -@use 'generate-material-palette' as palette; - -// @import '@angular/material/theming'; -@include mat.elevation-classes(); -@include mat.app-background(); - -$md-app-primary-color: palette.createpalette('primaryPalette'); -$md-app-accented-color: palette.createpalette('accentedPalette'); -$md-app-warn-color: palette.createpalette('warnPalette'); -$md-app-white-color: palette.createpalette('whitePalette'); -$md-app-warn-dark-color: palette.createpalette('warnDarkPalette'); - -$custom-palette-primary: mat.m2-define-palette($md-app-primary-color); -$custom-palette-accent: mat.m2-define-palette($md-app-accented-color); -$custom-palette-warn: mat.m2-define-palette($md-app-warn-color); -$custom-palette-white: mat.m2-define-palette($md-app-white-color); -// $custom-palette-accented-dark: mat.m2-define-palette($md-app-accented-dark-color); -$custom-palette-warn-dark: mat.m2-define-palette($md-app-warn-dark-color); - -html { - --mat-shape-small: 0px !important; - --mat-button-filled-container-shape: 4px; - --mat-button-outlined-container-shape: 4px; - --mat-button-text-container-shape: 4px; - --mat-form-field-outlined-container-shape: 4px !important; - - --surface-dark-color: #202020 !important; - --mat-toolbar-container-background-color: var(--surface-dark-color) !important; - - --error-background-color: color-mix(in hsl, var(--color-warnPalette-500), transparent 95%); - --warning-background-color: color-mix(in hsl, var(--color-warningPalette-500), transparent 95%); - --info-background-color: color-mix(in hsl, var(--color-infoPalette-500), transparent 95%); - --success-background-color: color-mix(in hsl, var(--color-successPalette-500), transparent 97%); - --alternative-background-color: color-mix(in hsl, var(--color-alternativePalette-500), transparent 95%); -} - -@media (prefers-color-scheme: dark) { - html { - --mat-expansion-container-background-color: var(--surface-dark-color) !important; - --mat-table-background-color: var(--surface-dark-color) !important; - --mat-paginator-container-background-color: var(--surface-dark-color) !important; - --mat-snack-bar-button-color: var(--color-accentedPalette-500) !important; - - --error-background-color: color-mix(in hsl, var(--color-warnDarkPalette-500), transparent 95%); - --warning-background-color: color-mix(in hsl, var(--color-warningPalette-500), transparent 95%); - --info-background-color: color-mix(in hsl, var(--color-infoPalette-500), transparent 95%); - --success-background-color: color-mix(in hsl, var(--color-successDarkPalette-500), transparent 95%); - --alternative-background-color: color-mix(in hsl, var(--color-alternativeDarkPalette-500), transparent 95%); - } - - .mat-datepicker-content { - --mat-datepicker-calendar-date-hover-state-background-color: rgba(255, 255, 255, 0.1) !important; - --mat-datepicker-calendar-date-focus-state-background-color: rgba(255, 255, 255, 0.1) !important; - } -} - -// .main-menu-container_native .mat-mdc-unelevated-button.mat-accent { -// --mat-button-filled-label-text-color: #fff !important; -// } - -@media (prefers-color-scheme: light) { - .mat-datepicker-content { - --mat-datepicker-calendar-date-hover-state-background-color: rgba(0, 0, 0, 0.04) !important; - --mat-datepicker-calendar-date-focus-state-background-color: rgba(0, 0, 0, 0.04) !important; - } - - .main-menu-container_native .mat-mdc-unelevated-button.mat-accent { - --mat-button-filled-label-text-color: #fff !important; - } - - body .mat-mdc-flat-button.mat-primary, body .mat-mdc-unelevated-button.mat-primary { - --mat-mdc-button-persistent-ripple-color: var(--color-primaryPalette-500-contrast) !important; - --mat-button-protected-label-text-color: var(--color-primaryPalette-500-contrast) !important; - --mat-button-filled-label-text-color: var(--color-primaryPalette-500-contrast) !important; - --mat-mdc-button-ripple-color: rgba(255, 255, 255, 0.1) !important; - } - - body .mat-mdc-flat-button.mat-accent, body .mat-mdc-unelevated-button.mat-accent { - --mat-mdc-button-persistent-ripple-color: var(--color-accentedPalette-500-contrast) !important; - --mat-button-protected-label-text-color: var(--color-accentedPalette-500-contrast) !important; - --mat-button-filled-label-text-color: var(--color-accentedPalette-500-contrast) !important; - --mat-mdc-button-ripple-color: rgba(255, 255, 255, 0.1) !important; - } - - body .mat-mdc-flat-button.mat-warn, body .mat-mdc-unelevated-button.mat-warn { - --mat-mdc-button-persistent-ripple-color: var(--color-warnPalette-500-contrast) !important; - --mat-button-protected-label-text-color: var(--color-warnPalette-500-contrast) !important; - --mat-button-filled-label-text-color: var(--color-warnPalette-500-contrast) !important; - --mat-mdc-button-ripple-color: rgba(255, 255, 255, 0.1) !important; - } -} - -// @media (prefers-color-scheme: dark) { -// body .mat-mdc-outlined-button:not(:disabled) { -// --mat-button-outlined-label-text-color: #fff !important; -// } - -// // body .mat-mdc-button:not(:disabled) { -// // --mat-button-text-label-text-color: #fff !important; -// // } -// } - -// .mat-mdc-checkbox .mdc-form-field { -// --mat-checkbox-selected-checkmark-color: var(--color-accentedPalette-500-contrast) !important; -// } - -.mat-mdc-checkbox .mdc-checkbox .mdc-checkbox__native-control:enabled ~ .mdc-checkbox__background .mdc-checkbox__checkmark { - --mat-checkbox-selected-checkmark-color: var(--color-accentedPalette-500-contrast) !important; -} - -.mat-elevation-z4 { - box-shadow: 0 2px 4px -1px #0003,0 4px 5px #00000024,0 1px 10px #0000001f; -} - -.mat-elevation-z2 { - box-shadow: 0 3px 1px -2px #0003,0 2px 2px #00000024,0 1px 5px #0000001f; -} - -.mat-mdc-form-field-bottom-align::before { - margin-left: -3px; -} - -$app-font-family: 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - -$button-custom-level: mat.m2-define-typography-level( - $font-family: $app-font-family, - $font-weight: 500, - $font-size: 14px, - $line-height: 34px, - $letter-spacing: normal, -); - -$body-1-custom-level: mat.m2-define-typography-level( - $font-family: $app-font-family, - $font-weight: 400, - $font-size: 14px, - $line-height: 20px, - $letter-spacing: normal, -); - -$caption-custom-level: mat.m2-define-typography-level( - $font-family: $app-font-family, - $font-weight: 400, - $font-size: 12px, - $line-height: 16px, - $letter-spacing: normal -); - -$custom-typography-config: mat.m2-define-typography-config( - $font-family: $app-font-family, - $button: $button-custom-level, - $body-1: $body-1-custom-level, - $caption: $caption-custom-level, -); - - -$custom-light-theme: mat.m2-define-light-theme(( - color: ( - primary: $custom-palette-primary, - accent: $custom-palette-accent, - warn: $custom-palette-warn, - ), - density: -3, -)); - -$custom-dark-theme: mat.m2-define-dark-theme(( - color: ( - primary: $custom-palette-white, - accent: $custom-palette-accent, - warn: $custom-palette-warn-dark, - ), - density: -3, -)); - -body { - @include mat.button-density(0); -} - -@media (prefers-color-scheme: light) { - :root { - @include mat.all-component-themes($custom-light-theme); - @include mat.all-component-typographies($custom-typography-config); - } -} - -@media (prefers-color-scheme: dark) { - :root { - @include mat.all-component-themes($custom-dark-theme); - @include mat.all-component-typographies($custom-typography-config); - } -} diff --git a/frontend-vue/src/directives/customValidity.spec.ts b/frontend/src/directives/customValidity.spec.ts similarity index 100% rename from frontend-vue/src/directives/customValidity.spec.ts rename to frontend/src/directives/customValidity.spec.ts diff --git a/frontend-vue/src/directives/customValidity.ts b/frontend/src/directives/customValidity.ts similarity index 100% rename from frontend-vue/src/directives/customValidity.ts rename to frontend/src/directives/customValidity.ts diff --git a/frontend-vue/src/env.d.ts b/frontend/src/env.d.ts similarity index 100% rename from frontend-vue/src/env.d.ts rename to frontend/src/env.d.ts diff --git a/frontend-vue/src/environment.spec.ts b/frontend/src/environment.spec.ts similarity index 100% rename from frontend-vue/src/environment.spec.ts rename to frontend/src/environment.spec.ts diff --git a/frontend-vue/src/environment.ts b/frontend/src/environment.ts similarity index 100% rename from frontend-vue/src/environment.ts rename to frontend/src/environment.ts diff --git a/frontend/src/environments/environment.dev.ts b/frontend/src/environments/environment.dev.ts deleted file mode 100644 index 24d2881ea..000000000 --- a/frontend/src/environments/environment.dev.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const environment = { - production: false, - saas: true, - apiRoot: 'https://app.rocketadmin.com/api', - saasURL: 'https://app.rocketadmin.com', - saasHostnames: ['localhost', '127.0.0.1'], - stagingHost: '', - version: '0.0.0', -}; diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts deleted file mode 100644 index 7ee4d9505..000000000 --- a/frontend/src/environments/environment.prod.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const environment = { - production: true, - saas: false, - apiRoot: '/api', - saasURL: '', - stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host - version: '0.0.0', -}; diff --git a/frontend/src/environments/environment.saas-prod.ts b/frontend/src/environments/environment.saas-prod.ts deleted file mode 100644 index bbf58cc7d..000000000 --- a/frontend/src/environments/environment.saas-prod.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const environment = { - production: true, - saas: true, - apiRoot: '/api', - saasURL: '', - saasHostnames: ['app.rocketadmin.com', 'rocket.sitenova.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], - stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host - version: '0.0.0', - turnstileSiteKey: '0x4AAAAAACM2ZuNYhGhncig_', -}; diff --git a/frontend/src/environments/environment.saas.ts b/frontend/src/environments/environment.saas.ts deleted file mode 100644 index f408ef2b6..000000000 --- a/frontend/src/environments/environment.saas.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const environment = { - saas: true, - production: false, - apiRoot: 'https://rocketadmin-dev.tail9f8b2.ts.net/api', - saasURL: 'https://rocketadmin-dev.tail9f8b2.ts.net', - saasHostnames: ['app.rocketadmin.com', 'rocket.sitenova.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], - stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host - version: '0.0.0', - turnstileSiteKey: '1x00000000000000000000AA', // Test key - always passes -}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts deleted file mode 100644 index 8964888f0..000000000 --- a/frontend/src/environments/environment.ts +++ /dev/null @@ -1,19 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. -// The list of file replacements can be found in `angular.json`. - -export const environment = { - production: false, - apiRoot: '/api', - stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host - version: '0.0.0', -} as Record; - -/* - * For easier debugging in development mode, you can import the following file - * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. - * - * This import should be commented out in production mode because it will have a negative impact - * on performance if an error is thrown. - */ -// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/frontend/src/index.html b/frontend/src/index.html deleted file mode 100644 index 372ba014f..000000000 --- a/frontend/src/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - Rocketadmin - - - - - - - - - - - diff --git a/frontend/src/index.saas.html b/frontend/src/index.saas.html deleted file mode 100644 index 6d4db281f..000000000 --- a/frontend/src/index.saas.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - Rocketadmin - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
- - - -
-
- -
- - diff --git a/frontend-vue/src/lib/ai-suggestions.spec.ts b/frontend/src/lib/ai-suggestions.spec.ts similarity index 100% rename from frontend-vue/src/lib/ai-suggestions.spec.ts rename to frontend/src/lib/ai-suggestions.spec.ts diff --git a/frontend-vue/src/lib/ai-suggestions.ts b/frontend/src/lib/ai-suggestions.ts similarity index 100% rename from frontend-vue/src/lib/ai-suggestions.ts rename to frontend/src/lib/ai-suggestions.ts diff --git a/frontend-vue/src/lib/binary.spec.ts b/frontend/src/lib/binary.spec.ts similarity index 100% rename from frontend-vue/src/lib/binary.spec.ts rename to frontend/src/lib/binary.spec.ts diff --git a/frontend-vue/src/lib/binary.ts b/frontend/src/lib/binary.ts similarity index 100% rename from frontend-vue/src/lib/binary.ts rename to frontend/src/lib/binary.ts diff --git a/frontend-vue/src/lib/cedar-monaco-language.ts b/frontend/src/lib/cedar-monaco-language.ts similarity index 100% rename from frontend-vue/src/lib/cedar-monaco-language.ts rename to frontend/src/lib/cedar-monaco-language.ts diff --git a/frontend-vue/src/lib/cedar-policy-items.ts b/frontend/src/lib/cedar-policy-items.ts similarity index 100% rename from frontend-vue/src/lib/cedar-policy-items.ts rename to frontend/src/lib/cedar-policy-items.ts diff --git a/frontend-vue/src/lib/cedar-policy-parser.ts b/frontend/src/lib/cedar-policy-parser.ts similarity index 100% rename from frontend-vue/src/lib/cedar-policy-parser.ts rename to frontend/src/lib/cedar-policy-parser.ts diff --git a/frontend-vue/src/lib/cedar-policy-roundtrip.spec.ts b/frontend/src/lib/cedar-policy-roundtrip.spec.ts similarity index 100% rename from frontend-vue/src/lib/cedar-policy-roundtrip.spec.ts rename to frontend/src/lib/cedar-policy-roundtrip.spec.ts diff --git a/frontend-vue/src/lib/cedar-wasm.ts b/frontend/src/lib/cedar-wasm.ts similarity index 100% rename from frontend-vue/src/lib/cedar-wasm.ts rename to frontend/src/lib/cedar-wasm.ts diff --git a/frontend-vue/src/lib/chart-config.helper.ts b/frontend/src/lib/chart-config.helper.ts similarity index 100% rename from frontend-vue/src/lib/chart-config.helper.ts rename to frontend/src/lib/chart-config.helper.ts diff --git a/frontend-vue/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts similarity index 100% rename from frontend-vue/src/lib/clipboard.ts rename to frontend/src/lib/clipboard.ts diff --git a/frontend-vue/src/lib/connection-theme.spec.ts b/frontend/src/lib/connection-theme.spec.ts similarity index 100% rename from frontend-vue/src/lib/connection-theme.spec.ts rename to frontend/src/lib/connection-theme.spec.ts diff --git a/frontend-vue/src/lib/connection-theme.ts b/frontend/src/lib/connection-theme.ts similarity index 100% rename from frontend-vue/src/lib/connection-theme.ts rename to frontend/src/lib/connection-theme.ts diff --git a/frontend-vue/src/lib/format-field-value.ts b/frontend/src/lib/format-field-value.ts similarity index 100% rename from frontend-vue/src/lib/format-field-value.ts rename to frontend/src/lib/format-field-value.ts diff --git a/frontend-vue/src/lib/markdown.spec.ts b/frontend/src/lib/markdown.spec.ts similarity index 100% rename from frontend-vue/src/lib/markdown.spec.ts rename to frontend/src/lib/markdown.spec.ts diff --git a/frontend-vue/src/lib/markdown.ts b/frontend/src/lib/markdown.ts similarity index 100% rename from frontend-vue/src/lib/markdown.ts rename to frontend/src/lib/markdown.ts diff --git a/frontend-vue/src/lib/mermaid-config.ts b/frontend/src/lib/mermaid-config.ts similarity index 100% rename from frontend-vue/src/lib/mermaid-config.ts rename to frontend/src/lib/mermaid-config.ts diff --git a/frontend-vue/src/lib/monaco.ts b/frontend/src/lib/monaco.ts similarity index 100% rename from frontend-vue/src/lib/monaco.ts rename to frontend/src/lib/monaco.ts diff --git a/frontend-vue/src/lib/monitoring.spec.ts b/frontend/src/lib/monitoring.spec.ts similarity index 100% rename from frontend-vue/src/lib/monitoring.spec.ts rename to frontend/src/lib/monitoring.spec.ts diff --git a/frontend-vue/src/lib/monitoring.ts b/frontend/src/lib/monitoring.ts similarity index 100% rename from frontend-vue/src/lib/monitoring.ts rename to frontend/src/lib/monitoring.ts diff --git a/frontend-vue/src/lib/normalize.spec.ts b/frontend/src/lib/normalize.spec.ts similarity index 100% rename from frontend-vue/src/lib/normalize.spec.ts rename to frontend/src/lib/normalize.spec.ts diff --git a/frontend-vue/src/lib/normalize.ts b/frontend/src/lib/normalize.ts similarity index 100% rename from frontend-vue/src/lib/normalize.ts rename to frontend/src/lib/normalize.ts diff --git a/frontend-vue/src/lib/parse-filter-params.spec.ts b/frontend/src/lib/parse-filter-params.spec.ts similarity index 100% rename from frontend-vue/src/lib/parse-filter-params.spec.ts rename to frontend/src/lib/parse-filter-params.spec.ts diff --git a/frontend-vue/src/lib/parse-filter-params.ts b/frontend/src/lib/parse-filter-params.ts similarity index 100% rename from frontend-vue/src/lib/parse-filter-params.ts rename to frontend/src/lib/parse-filter-params.ts diff --git a/frontend-vue/src/lib/password-strength.spec.ts b/frontend/src/lib/password-strength.spec.ts similarity index 100% rename from frontend-vue/src/lib/password-strength.spec.ts rename to frontend/src/lib/password-strength.spec.ts diff --git a/frontend-vue/src/lib/password-strength.ts b/frontend/src/lib/password-strength.ts similarity index 100% rename from frontend-vue/src/lib/password-strength.ts rename to frontend/src/lib/password-strength.ts diff --git a/frontend-vue/src/lib/permission-display.spec.ts b/frontend/src/lib/permission-display.spec.ts similarity index 100% rename from frontend-vue/src/lib/permission-display.spec.ts rename to frontend/src/lib/permission-display.spec.ts diff --git a/frontend-vue/src/lib/permission-display.ts b/frontend/src/lib/permission-display.ts similarity index 100% rename from frontend-vue/src/lib/permission-display.ts rename to frontend/src/lib/permission-display.ts diff --git a/frontend-vue/src/lib/setup-table-row-structure.spec.ts b/frontend/src/lib/setup-table-row-structure.spec.ts similarity index 100% rename from frontend-vue/src/lib/setup-table-row-structure.spec.ts rename to frontend/src/lib/setup-table-row-structure.spec.ts diff --git a/frontend-vue/src/lib/setup-table-row-structure.ts b/frontend/src/lib/setup-table-row-structure.ts similarity index 100% rename from frontend-vue/src/lib/setup-table-row-structure.ts rename to frontend/src/lib/setup-table-row-structure.ts diff --git a/frontend-vue/src/lib/table-events.spec.ts b/frontend/src/lib/table-events.spec.ts similarity index 100% rename from frontend-vue/src/lib/table-events.spec.ts rename to frontend/src/lib/table-events.spec.ts diff --git a/frontend-vue/src/lib/table-events.ts b/frontend/src/lib/table-events.ts similarity index 100% rename from frontend-vue/src/lib/table-events.ts rename to frontend/src/lib/table-events.ts diff --git a/frontend-vue/src/lib/table-query.spec.ts b/frontend/src/lib/table-query.spec.ts similarity index 100% rename from frontend-vue/src/lib/table-query.spec.ts rename to frontend/src/lib/table-query.spec.ts diff --git a/frontend-vue/src/lib/table-query.ts b/frontend/src/lib/table-query.ts similarity index 100% rename from frontend-vue/src/lib/table-query.ts rename to frontend/src/lib/table-query.ts diff --git a/frontend-vue/src/lib/table-url.spec.ts b/frontend/src/lib/table-url.spec.ts similarity index 100% rename from frontend-vue/src/lib/table-url.spec.ts rename to frontend/src/lib/table-url.spec.ts diff --git a/frontend-vue/src/lib/table-url.ts b/frontend/src/lib/table-url.ts similarity index 100% rename from frontend-vue/src/lib/table-url.ts rename to frontend/src/lib/table-url.ts diff --git a/frontend-vue/src/lib/table-view.spec.ts b/frontend/src/lib/table-view.spec.ts similarity index 100% rename from frontend-vue/src/lib/table-view.spec.ts rename to frontend/src/lib/table-view.spec.ts diff --git a/frontend-vue/src/lib/table-view.ts b/frontend/src/lib/table-view.ts similarity index 100% rename from frontend-vue/src/lib/table-view.ts rename to frontend/src/lib/table-view.ts diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ca135a3f4..14aef6090 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,191 +1,61 @@ -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { DragDropModule } from '@angular/cdk/drag-drop'; -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { APP_INITIALIZER, ErrorHandler, enableProdMode, importProvidersFrom } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MAT_ICON_DEFAULT_OPTIONS, MatIconDefaultOptions } from '@angular/material/icon'; -import { BrowserModule, bootstrapApplication, Title } from '@angular/platform-browser'; -import { provideAnimations } from '@angular/platform-browser/animations'; -import { Router, RouterModule } from '@angular/router'; -import { IColorConfig, NgxThemeModule } from '@brumeilde/ngx-theme'; -import { provideCodeEditor } from '@ngstack/code-editor'; -import * as Sentry from '@sentry/angular'; -import { Angulartics2Module } from 'angulartics2'; -import { DynamicModule } from 'ng-dynamic-component'; -import { SignalComponentIoModule } from 'ng-dynamic-component/signal-component-io'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; -import { CookieService } from 'ngx-cookie-service'; -import { MarkdownModule, MERMAID_OPTIONS, provideMarkdown } from 'ngx-markdown'; -import { AppComponent } from './app/app.component'; -import { AppRoutingModule } from './app/app-routing.module'; -import { ConfigModule } from './app/modules/config.module'; -import { ConnectionsService } from './app/services/connections.service'; -import { NotificationsService } from './app/services/notifications.service'; -import { TablesService } from './app/services/tables.service'; -import { TokenInterceptor } from './app/services/token.interceptor'; -import { EncodeUrlParamsSafelyInterceptor } from './app/services/url-params.interceptor'; -import { UsersService } from './app/services/users.service'; -import { environment } from './environments/environment'; +// Self-hosted brand fonts first so their @font-face rules land before the theme, then the tokens, +// then base.css (which reads them). .storybook/preview.ts mirrors this order — keep them together. +import '@fontsource-variable/geist'; +import '@fontsource-variable/space-grotesk'; +import '@fontsource-variable/geist-mono'; +import '@material-symbols/font-400/outlined.css'; +import './assets/theme.css'; +import './assets/base.css'; -const saasExtraProviders = (environment as any).saas - ? [ - { - provide: Sentry.TraceService, - deps: [Router], - }, - { - provide: ErrorHandler, - useValue: Sentry.createErrorHandler({ - showDialog: true, - }), - }, - ] - : []; -const colorConfig: IColorConfig = { - palettes: { - primaryPalette: '#212121', - accentedPalette: '#2563eb', - warnPalette: '#B71C1C', - whitePalette: '#FFFFFF', - warnDarkPalette: '#E53935', - warningPalette: '#f79008', - infoPalette: '#296ee9', - successPalette: '#008027', - alternativePalette: '#6d28d9', - successDarkPalette: '#4caf50', - alternativeDarkPalette: '#c084fc', - }, - simpleColors: { myColorName: '#2e959a' }, -}; -type Palettes = { - primaryPalette: string; - accentedPalette: string; - warnPalette: string; - whitePalette: string; - warnDarkPalette: string; - warningPalette: string; - infoPalette: string; - successPalette: string; - alternativePalette: string; - successDarkPalette: string; - alternativeDarkPalette: string; -}; -type Colors = { myColorName: string }; +import { VueQueryPlugin } from '@tanstack/vue-query'; +import { createPinia } from 'pinia'; +import { createApp } from 'vue'; +import App from './App.vue'; +import { isApiError } from './api/client'; +import { vTooltip } from './components/ui'; +import { loadConfig } from './config'; +import { initMonitoring } from './lib/monitoring'; +import { router } from './router'; +import { useAuthStore } from './stores/auth'; +import { bindConnectionsToRouter } from './stores/connections'; +import { bindTablesToRouter } from './stores/tables'; -if (environment.production) { - enableProdMode(); -} +async function bootstrap() { + await loadConfig('/config.json'); + + const app = createApp(App); + const pinia = createPinia(); + app.use(pinia); + app.directive('tooltip', vTooltip); + initMonitoring(app, router); -if ((environment as Record).saas) { - Sentry.init({ - dsn: 'https://4d774c4c2c8a8f733cb4d43599cc0dc6@o4506084700389376.ingest.sentry.io/4506084702486528', - integrations: [Sentry.browserTracingIntegration()], - // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled - tracePropagationTargets: ['localhost', /^https:\/\/app\.rocketadmin\.com\/api/], - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions - // Session Replay - replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. - replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + // An awaited request that failed has already been shown to the user (see ApiError.notified); + // letting it reach the console as an unhandled error would only add noise. + const defaultHandler = app.config.errorHandler; + app.config.errorHandler = (error, instance, info) => { + if (isApiError(error) && error.notified) return; + if (defaultHandler) defaultHandler(error, instance, info); + else console.error(error); + }; + window.addEventListener('unhandledrejection', (event) => { + if (isApiError(event.reason) && event.reason.notified) event.preventDefault(); }); + + // Before the router resolves the first route: the guards read the restored session. + useAuthStore(pinia).restoreSession(); + + // Queries behave like Angular's httpResource: one attempt (the client already reported the + // failure) and no refetch on window focus. + // Angular read the current connection and table from the URL on every navigation. + bindConnectionsToRouter(router); + bindTablesToRouter(router); + + app + .use(router) + .use(VueQueryPlugin, { + queryClientConfig: { defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } } }, + }) + .mount('#app'); } -bootstrapApplication(AppComponent, { - providers: [ - importProvidersFrom( - BrowserModule, - AppRoutingModule, - FormsModule, - ReactiveFormsModule, - RouterModule, - DynamicModule, - SignalComponentIoModule, - Angulartics2Module.forRoot(), - ClipboardModule, - DragDropModule, - MarkdownModule.forRoot(), - // ...saasExtraModules, - NgxThemeModule.forRoot(colorConfig, { - frameworks: ['material'], // optional, default : ['tailwind', 'material'] - }), - ConfigModule.buildForConfigUrl('/config.json'), - ), - provideCodeEditor({ - baseUrl: 'assets/monaco', - }), - ConnectionsService, - UsersService, - NotificationsService, - TablesService, - CookieService, - provideMarkdown({ - mermaidOptions: { - provide: MERMAID_OPTIONS, - useValue: window.matchMedia('(prefers-color-scheme: dark)').matches ? { - startOnLoad: false, - theme: 'base', - themeVariables: { - darkMode: true, - background: '#15102b', - primaryColor: '#1d143a', - primaryBorderColor: '#3d2b6e', - primaryTextColor: '#ede9fe', - secondaryColor: '#251a47', - tertiaryColor: '#1d143a', - lineColor: '#a78bfa', - mainBkg: '#1d143a', - nodeBkg: '#1d143a', - nodeBorder: '#3d2b6e', - clusterBkg: '#1d143a', - clusterBorder: '#3d2b6e', - attributeBackgroundColorOdd: '#1d143a', - attributeBackgroundColorEven: '#1d143a', - titleColor: '#ede9fe', - edgeLabelBackground: '#1d143a', - textColor: '#ede9fe', - labelTextColor: '#ede9fe', - nodeTextColor: '#ede9fe', - }, - } : { - startOnLoad: false, - theme: 'default', - }, - }, - }), - Title, - { - provide: HTTP_INTERCEPTORS, - useClass: TokenInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: EncodeUrlParamsSafelyInterceptor, - multi: true, - }, - { - provide: APP_INITIALIZER, - useFactory: () => () => {}, - deps: (environment as Record).saas ? [Sentry.TraceService] : [], - multi: true, - }, - { - provide: 'COLOR_CONFIG', - useValue: colorConfig, - }, - { - provide: 'THEME_OPTIONS', - useValue: { frameworks: ['material'] }, - }, - ...saasExtraProviders, - provideHttpClient(withInterceptorsFromDi()), - provideAnimations(), - provideCharts(withDefaultRegisterables()), - { - provide: MAT_ICON_DEFAULT_OPTIONS, - useValue: { - fontSet: 'material-symbols-outlined', - } as MatIconDefaultOptions, - }, - ], -}).catch((err) => console.error(err)); +bootstrap(); diff --git a/frontend-vue/src/models/ai-suggestions.ts b/frontend/src/models/ai-suggestions.ts similarity index 100% rename from frontend-vue/src/models/ai-suggestions.ts rename to frontend/src/models/ai-suggestions.ts diff --git a/frontend-vue/src/models/alert.ts b/frontend/src/models/alert.ts similarity index 100% rename from frontend-vue/src/models/alert.ts rename to frontend/src/models/alert.ts diff --git a/frontend-vue/src/models/company.ts b/frontend/src/models/company.ts similarity index 100% rename from frontend-vue/src/models/company.ts rename to frontend/src/models/company.ts diff --git a/frontend-vue/src/models/connection.ts b/frontend/src/models/connection.ts similarity index 100% rename from frontend-vue/src/models/connection.ts rename to frontend/src/models/connection.ts diff --git a/frontend-vue/src/models/dashboard.ts b/frontend/src/models/dashboard.ts similarity index 100% rename from frontend-vue/src/models/dashboard.ts rename to frontend/src/models/dashboard.ts diff --git a/frontend-vue/src/models/logs.ts b/frontend/src/models/logs.ts similarity index 100% rename from frontend-vue/src/models/logs.ts rename to frontend/src/models/logs.ts diff --git a/frontend-vue/src/models/saved-query.ts b/frontend/src/models/saved-query.ts similarity index 100% rename from frontend-vue/src/models/saved-query.ts rename to frontend/src/models/saved-query.ts diff --git a/frontend-vue/src/models/secret.ts b/frontend/src/models/secret.ts similarity index 100% rename from frontend-vue/src/models/secret.ts rename to frontend/src/models/secret.ts diff --git a/frontend-vue/src/models/table.ts b/frontend/src/models/table.ts similarity index 100% rename from frontend-vue/src/models/table.ts rename to frontend/src/models/table.ts diff --git a/frontend-vue/src/models/ui-settings.ts b/frontend/src/models/ui-settings.ts similarity index 100% rename from frontend-vue/src/models/ui-settings.ts rename to frontend/src/models/ui-settings.ts diff --git a/frontend-vue/src/models/user.ts b/frontend/src/models/user.ts similarity index 100% rename from frontend-vue/src/models/user.ts rename to frontend/src/models/user.ts diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts deleted file mode 100644 index 43c98ea7f..000000000 --- a/frontend/src/polyfills.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/guide/browser-support - */ - -/*************************************************************************************************** - * BROWSER POLYFILLS - */ - -/** IE10 and IE11 requires the following for NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** - * Web Animations `@angular/platform-browser/animations` - * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. - * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). - */ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - * because those flags need to be set before `zone.js` being loaded, and webpack - * will put import in the top of bundle, so user need to create a separate file - * in this directory (for example: zone-flags.ts), and put the following flags - * into that file, and then add the following code before importing zone.js. - * import './zone-flags.ts'; - * - * The flags allowed in zone-flags.ts are listed here. - * - * The following flags will work for all browsers. - * - * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame - * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick - * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - * - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - * - * (window as any).__Zone_enable_cross_context_check = true; - * - */ - -/*************************************************************************************************** - * Zone JS is required by default for Angular itself. - */ -import 'zone.js'; - -// Included with Angular CLI. - -/*************************************************************************************************** - * APPLICATION IMPORTS - */ - -(window as any).global = window; -(window as any).process = { - env: { DEBUG: undefined }, -}; diff --git a/frontend/src/proxy.conf.json b/frontend/src/proxy.conf.json deleted file mode 100644 index 01c99fe49..000000000 --- a/frontend/src/proxy.conf.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "/api/**": { - "target": "https://app.rocketadmin.com/api/", - "secure": false - } -} diff --git a/frontend-vue/src/router/guards.spec.ts b/frontend/src/router/guards.spec.ts similarity index 100% rename from frontend-vue/src/router/guards.spec.ts rename to frontend/src/router/guards.spec.ts diff --git a/frontend-vue/src/router/guards.ts b/frontend/src/router/guards.ts similarity index 100% rename from frontend-vue/src/router/guards.ts rename to frontend/src/router/guards.ts diff --git a/frontend-vue/src/router/index.ts b/frontend/src/router/index.ts similarity index 100% rename from frontend-vue/src/router/index.ts rename to frontend/src/router/index.ts diff --git a/frontend-vue/src/router/routes.spec.ts b/frontend/src/router/routes.spec.ts similarity index 100% rename from frontend-vue/src/router/routes.spec.ts rename to frontend/src/router/routes.spec.ts diff --git a/frontend-vue/src/router/routes.ts b/frontend/src/router/routes.ts similarity index 100% rename from frontend-vue/src/router/routes.ts rename to frontend/src/router/routes.ts diff --git a/frontend-vue/src/stores/ai-suggestions.spec.ts b/frontend/src/stores/ai-suggestions.spec.ts similarity index 100% rename from frontend-vue/src/stores/ai-suggestions.spec.ts rename to frontend/src/stores/ai-suggestions.spec.ts diff --git a/frontend-vue/src/stores/ai-suggestions.ts b/frontend/src/stores/ai-suggestions.ts similarity index 100% rename from frontend-vue/src/stores/ai-suggestions.ts rename to frontend/src/stores/ai-suggestions.ts diff --git a/frontend-vue/src/stores/ai.spec.ts b/frontend/src/stores/ai.spec.ts similarity index 100% rename from frontend-vue/src/stores/ai.spec.ts rename to frontend/src/stores/ai.spec.ts diff --git a/frontend-vue/src/stores/ai.ts b/frontend/src/stores/ai.ts similarity index 100% rename from frontend-vue/src/stores/ai.ts rename to frontend/src/stores/ai.ts diff --git a/frontend-vue/src/stores/auth.spec.ts b/frontend/src/stores/auth.spec.ts similarity index 100% rename from frontend-vue/src/stores/auth.spec.ts rename to frontend/src/stores/auth.spec.ts diff --git a/frontend-vue/src/stores/auth.ts b/frontend/src/stores/auth.ts similarity index 100% rename from frontend-vue/src/stores/auth.ts rename to frontend/src/stores/auth.ts diff --git a/frontend-vue/src/stores/cedar-permissions.spec.ts b/frontend/src/stores/cedar-permissions.spec.ts similarity index 100% rename from frontend-vue/src/stores/cedar-permissions.spec.ts rename to frontend/src/stores/cedar-permissions.spec.ts diff --git a/frontend-vue/src/stores/cedar-permissions.ts b/frontend/src/stores/cedar-permissions.ts similarity index 100% rename from frontend-vue/src/stores/cedar-permissions.ts rename to frontend/src/stores/cedar-permissions.ts diff --git a/frontend-vue/src/stores/company.spec.ts b/frontend/src/stores/company.spec.ts similarity index 100% rename from frontend-vue/src/stores/company.spec.ts rename to frontend/src/stores/company.spec.ts diff --git a/frontend-vue/src/stores/company.ts b/frontend/src/stores/company.ts similarity index 100% rename from frontend-vue/src/stores/company.ts rename to frontend/src/stores/company.ts diff --git a/frontend-vue/src/stores/configuration-state.spec.ts b/frontend/src/stores/configuration-state.spec.ts similarity index 100% rename from frontend-vue/src/stores/configuration-state.spec.ts rename to frontend/src/stores/configuration-state.spec.ts diff --git a/frontend-vue/src/stores/configuration-state.ts b/frontend/src/stores/configuration-state.ts similarity index 100% rename from frontend-vue/src/stores/configuration-state.ts rename to frontend/src/stores/configuration-state.ts diff --git a/frontend-vue/src/stores/connections.spec.ts b/frontend/src/stores/connections.spec.ts similarity index 100% rename from frontend-vue/src/stores/connections.spec.ts rename to frontend/src/stores/connections.spec.ts diff --git a/frontend-vue/src/stores/connections.ts b/frontend/src/stores/connections.ts similarity index 100% rename from frontend-vue/src/stores/connections.ts rename to frontend/src/stores/connections.ts diff --git a/frontend-vue/src/stores/dashboards.spec.ts b/frontend/src/stores/dashboards.spec.ts similarity index 100% rename from frontend-vue/src/stores/dashboards.spec.ts rename to frontend/src/stores/dashboards.spec.ts diff --git a/frontend-vue/src/stores/dashboards.ts b/frontend/src/stores/dashboards.ts similarity index 100% rename from frontend-vue/src/stores/dashboards.ts rename to frontend/src/stores/dashboards.ts diff --git a/frontend-vue/src/stores/master-password.spec.ts b/frontend/src/stores/master-password.spec.ts similarity index 100% rename from frontend-vue/src/stores/master-password.spec.ts rename to frontend/src/stores/master-password.spec.ts diff --git a/frontend-vue/src/stores/master-password.ts b/frontend/src/stores/master-password.ts similarity index 100% rename from frontend-vue/src/stores/master-password.ts rename to frontend/src/stores/master-password.ts diff --git a/frontend-vue/src/stores/notifications.spec.ts b/frontend/src/stores/notifications.spec.ts similarity index 100% rename from frontend-vue/src/stores/notifications.spec.ts rename to frontend/src/stores/notifications.spec.ts diff --git a/frontend-vue/src/stores/notifications.ts b/frontend/src/stores/notifications.ts similarity index 100% rename from frontend-vue/src/stores/notifications.ts rename to frontend/src/stores/notifications.ts diff --git a/frontend-vue/src/stores/s3.spec.ts b/frontend/src/stores/s3.spec.ts similarity index 100% rename from frontend-vue/src/stores/s3.spec.ts rename to frontend/src/stores/s3.spec.ts diff --git a/frontend-vue/src/stores/s3.ts b/frontend/src/stores/s3.ts similarity index 100% rename from frontend-vue/src/stores/s3.ts rename to frontend/src/stores/s3.ts diff --git a/frontend-vue/src/stores/saved-queries.spec.ts b/frontend/src/stores/saved-queries.spec.ts similarity index 100% rename from frontend-vue/src/stores/saved-queries.spec.ts rename to frontend/src/stores/saved-queries.spec.ts diff --git a/frontend-vue/src/stores/saved-queries.ts b/frontend/src/stores/saved-queries.ts similarity index 100% rename from frontend-vue/src/stores/saved-queries.ts rename to frontend/src/stores/saved-queries.ts diff --git a/frontend-vue/src/stores/secrets.spec.ts b/frontend/src/stores/secrets.spec.ts similarity index 100% rename from frontend-vue/src/stores/secrets.spec.ts rename to frontend/src/stores/secrets.spec.ts diff --git a/frontend-vue/src/stores/secrets.ts b/frontend/src/stores/secrets.ts similarity index 100% rename from frontend-vue/src/stores/secrets.ts rename to frontend/src/stores/secrets.ts diff --git a/frontend-vue/src/stores/selfhosted.ts b/frontend/src/stores/selfhosted.ts similarity index 100% rename from frontend-vue/src/stores/selfhosted.ts rename to frontend/src/stores/selfhosted.ts diff --git a/frontend-vue/src/stores/table-row.spec.ts b/frontend/src/stores/table-row.spec.ts similarity index 100% rename from frontend-vue/src/stores/table-row.spec.ts rename to frontend/src/stores/table-row.spec.ts diff --git a/frontend-vue/src/stores/table-row.ts b/frontend/src/stores/table-row.ts similarity index 100% rename from frontend-vue/src/stores/table-row.ts rename to frontend/src/stores/table-row.ts diff --git a/frontend-vue/src/stores/table-schema.spec.ts b/frontend/src/stores/table-schema.spec.ts similarity index 100% rename from frontend-vue/src/stores/table-schema.spec.ts rename to frontend/src/stores/table-schema.spec.ts diff --git a/frontend-vue/src/stores/table-schema.ts b/frontend/src/stores/table-schema.ts similarity index 100% rename from frontend-vue/src/stores/table-schema.ts rename to frontend/src/stores/table-schema.ts diff --git a/frontend-vue/src/stores/table-state.spec.ts b/frontend/src/stores/table-state.spec.ts similarity index 100% rename from frontend-vue/src/stores/table-state.spec.ts rename to frontend/src/stores/table-state.spec.ts diff --git a/frontend-vue/src/stores/table-state.ts b/frontend/src/stores/table-state.ts similarity index 100% rename from frontend-vue/src/stores/table-state.ts rename to frontend/src/stores/table-state.ts diff --git a/frontend-vue/src/stores/tables.spec.ts b/frontend/src/stores/tables.spec.ts similarity index 100% rename from frontend-vue/src/stores/tables.spec.ts rename to frontend/src/stores/tables.spec.ts diff --git a/frontend-vue/src/stores/tables.ts b/frontend/src/stores/tables.ts similarity index 100% rename from frontend-vue/src/stores/tables.ts rename to frontend/src/stores/tables.ts diff --git a/frontend-vue/src/stores/ui-settings.spec.ts b/frontend/src/stores/ui-settings.spec.ts similarity index 100% rename from frontend-vue/src/stores/ui-settings.spec.ts rename to frontend/src/stores/ui-settings.spec.ts diff --git a/frontend-vue/src/stores/ui-settings.ts b/frontend/src/stores/ui-settings.ts similarity index 100% rename from frontend-vue/src/stores/ui-settings.ts rename to frontend/src/stores/ui-settings.ts diff --git a/frontend-vue/src/stores/user.spec.ts b/frontend/src/stores/user.spec.ts similarity index 100% rename from frontend-vue/src/stores/user.spec.ts rename to frontend/src/stores/user.spec.ts diff --git a/frontend-vue/src/stores/user.ts b/frontend/src/stores/user.ts similarity index 100% rename from frontend-vue/src/stores/user.ts rename to frontend/src/stores/user.ts diff --git a/frontend-vue/src/stores/users.spec.ts b/frontend/src/stores/users.spec.ts similarity index 100% rename from frontend-vue/src/stores/users.spec.ts rename to frontend/src/stores/users.spec.ts diff --git a/frontend-vue/src/stores/users.ts b/frontend/src/stores/users.ts similarity index 100% rename from frontend-vue/src/stores/users.ts rename to frontend/src/stores/users.ts diff --git a/frontend-vue/src/stores/view-as.spec.ts b/frontend/src/stores/view-as.spec.ts similarity index 100% rename from frontend-vue/src/stores/view-as.spec.ts rename to frontend/src/stores/view-as.spec.ts diff --git a/frontend-vue/src/stores/view-as.ts b/frontend/src/stores/view-as.ts similarity index 100% rename from frontend-vue/src/stores/view-as.ts rename to frontend/src/stores/view-as.ts diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss deleted file mode 100644 index 07e745d9b..000000000 --- a/frontend/src/styles.scss +++ /dev/null @@ -1,246 +0,0 @@ -// @import 'codemirror/lib/codemirror'; -// @import 'codemirror/theme/ttcn'; - - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - color: #0a0a0a; - // font-family: Poppins, sans-serif; -} - -html, body { height: 100%; } -body { - background-color: #fff; - margin: 0; -} - -.mainLayout { - display: flex; - flex-direction: column; - min-height: 100%; -} - -.background-decoration { - background-image: - url('./assets/bg/decor-left.svg'), - url('./assets/bg/decor-right.svg'); - background-repeat: no-repeat; - background-position: left 100%, right -60%; - background-size: 20%, 25%; - height: 100%; - min-height: calc(100vh - 56px); -} - -@media (prefers-color-scheme: dark) { - .background-decoration { - background-image: - url('./assets/bg/decor-left_dark.svg'), - url('./assets/bg/decor-right_dark.svg'); - } -} - -.skeleton { - position: relative; - overflow: hidden; - width: 100%; -} - -.skeleton::after { - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - transform: translateX(-100%); - // background-image: linear-gradient( - // 90deg, - // transparent 40%, - // rgba(255,255,255, 1), - // transparent 60% - // ); - animation: shimmer 1000ms ease-in-out infinite alternate; -} - -@media (prefers-color-scheme: light) { - .skeleton::after { - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #fff), - transparent 60% - ); - } -} - -@media (prefers-color-scheme: dark) { - .skeleton::after { - background-image: linear-gradient( - 90deg, - transparent 40%, - var(--mat-sidenav-content-background-color, #303030), - transparent 60% - ); - } -} - -body:has(.ai-panel-sidebar-content_open) .add-row-fab { - display: none !important; -} - -@keyframes shimmer { - 100% { - transform: translateX(100%); - } -} - -.bone { - display: block; - background-color: rgba(0, 0, 0, 0.06); - mix-blend-mode: difference; -} - -@media (width <= 600px) { - .cdk-overlay-pane.mobile-bottom-sheet-dialog { - position: fixed !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - top: auto !important; - max-width: 100vw !important; - width: 100vw !important; - margin: 0 !important; - animation: mobileBottomSheetSlideUp 260ms cubic-bezier(0.2, 0.8, 0.2, 1); - } - - .mobile-bottom-sheet-dialog .mat-mdc-dialog-container { - --mdc-dialog-container-shape: 16px 16px 0 0; - max-height: 85vh; - padding-bottom: env(safe-area-inset-bottom, 0px); - } - - .mobile-bottom-sheet-dialog .mat-mdc-dialog-surface { - position: relative; - border-radius: 16px 16px 0 0 !important; - box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18) !important; - max-height: 85vh; - overflow: hidden; - padding-top: 16px !important; - display: flex; - flex-direction: column; - } - - .mobile-bottom-sheet-dialog .filters-header { - margin-top: 8px !important; - margin-bottom: 8px !important; - } - - .mobile-bottom-sheet-dialog .filters-content { - padding-top: 12px !important; - } - - .mobile-bottom-sheet-dialog .filters-select { - margin-top: 4px; - } - - .mobile-bottom-sheet-dialog .mat-mdc-dialog-surface::before { - content: ""; - position: absolute; - top: 8px; - left: 50%; - transform: translateX(-50%); - width: 36px; - height: 4px; - border-radius: 2px; - background: rgba(0, 0, 0, 0.2); - z-index: 10; - } -} - -@media (prefers-color-scheme: dark) and (width <= 600px) { - .mobile-bottom-sheet-dialog .mat-mdc-dialog-surface::before { - background: rgba(255, 255, 255, 0.24); - } -} - -@keyframes mobileBottomSheetSlideUp { - from { - transform: translateY(100%); - } - to { - transform: translateY(0); - } -} - -@media (width <= 600px) { - .cdk-overlay-pane:has(.mat-mdc-select-panel) { - min-width: 200px !important; - width: max-content !important; - max-width: calc(100vw - 32px) !important; - } - - .mat-mdc-select-panel { - min-width: 200px !important; - width: max-content !important; - max-width: calc(100vw - 32px) !important; - } - - .comparator-field { - min-width: 0 !important; - max-width: 116px !important; - flex: 0 0 auto !important; - } - - .comparator-field .mat-mdc-text-field-wrapper { - padding-left: 8px !important; - padding-right: 0 !important; - } - - .comparator-field .mat-mdc-form-field-infix { - min-height: 36px !important; - padding-top: 8px !important; - padding-bottom: 8px !important; - width: auto !important; - } - - .comparator-field .mat-mdc-select-arrow-wrapper { - padding-right: 4px; - } - - .filter-row .value-field { - flex: 1 1 0 !important; - min-width: 0 !important; - } - - .mat-mdc-form-field .mdc-notched-outline__notch, - .mat-mdc-form-field .mdc-notched-outline { - max-width: none !important; - overflow: visible !important; - } - - .mat-mdc-form-field .mdc-floating-label, - .mat-mdc-form-field .mdc-floating-label--float-above { - max-width: none !important; - white-space: nowrap !important; - overflow: visible !important; - text-overflow: clip !important; - } - - .mat-mdc-form-field-flex { - overflow: visible !important; - } -} - - -@media (width <= 600px) { - /* Hide cell copy buttons on mobile — invisible (no hover) but still - tappable, causing accidental copies instead of opening row preview. */ - app-db-table-view .field-copy-button { - display: none !important; - } -} diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 89b4d63f9..433f66aeb 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,16 +1,24 @@ -/// -import 'zone.js'; -import 'zone.js/testing'; -import { TestBed } from '@angular/core/testing'; -import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; import { vi } from 'vitest'; -// Initialize Angular testing environment -try { - TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); -} catch (e) { - // TestBed already initialized +// jsdom implements no layout, so the observer APIs are missing and a component that creates one +// on mount would throw. The stubs never fire: jsdom reports every box as 0×0 anyway. +class NoopObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): [] { + return []; + } } -// Expose vi globally for tests that need it -(globalThis as any).vi = vi; +globalThis.ResizeObserver ??= NoopObserver as unknown as typeof ResizeObserver; +globalThis.IntersectionObserver ??= NoopObserver as unknown as typeof IntersectionObserver; + +// Monaco can't run in jsdom (no layout, no workers). Rejecting the loader leaves AppCodeEditor on +// its