diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5902cbf66 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.DS_Store + +frontend/node_modules +frontend/.next + +web/bin +web/obj diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 4bfd95642..95c741063 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -17,7 +17,7 @@ jobs: - name: setup dotnet ef run: dotnet tool install --tool-path .\.dotnet-tools dotnet-ef --version 9.0.11 - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' - name: install node deps diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9aba64a74..1b3b29f98 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 persist-credentials: false - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' - name: install node deps diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6b8136283..5f845f382 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -16,14 +16,14 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Cache SonarCloud packages - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~\sonar\cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache SonarCloud scanner id: cache-sonar-scanner - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: .\.sonar\scanner key: ${{ runner.os }}-sonar-scanner diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a1c4f66b0..97652b017 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,16 +14,63 @@ jobs: - name: checkout uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + with: + version: 10.34.5 + - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22.x' + cache: 'pnpm' - name: install node deps - run: npm install --ignore-scripts --no-audit --no-fund + run: pnpm install --ignore-scripts --frozen-lockfile - name: lint - run: npm run lint + run: pnpm run lint + frontend_tests: + name: 'frontend tests' + runs-on: ubuntu-latest + + steps: + - name: checkout + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + with: + version: 10.34.5 + + - name: setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: '22.x' + cache: 'pnpm' + + - name: install node deps + run: pnpm install --ignore-scripts --frozen-lockfile + + - name: test with coverage + run: pnpm run frontend:test:coverage + + - name: upload cov + if: always() + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de + with: + files: ./frontend/coverage/cobertura-coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + verbose: true + + - name: upload test results + if: ${{ !cancelled() }} + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./frontend/coverage/test-report.junit.xml + report-type: test_results install_test: name: 'install test' runs-on: windows-latest @@ -38,14 +85,19 @@ jobs: uses: actions/setup-dotnet@131b410979e0b49e2162c0718030257b22d6dc2c with: dotnet-version: '9.0.x' + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + with: + version: 10.34.5 - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22.x' + cache: 'pnpm' - name: install node deps - run: npm install --ignore-scripts --no-audit --no-fund + run: pnpm install --ignore-scripts --frozen-lockfile - name: node build - run: npm run build + run: pnpm run build - name: create settings file run: | cd web @@ -79,31 +131,36 @@ jobs: dotnet-version: '9.0.x' - name: setup dotnet ef run: dotnet tool install --tool-path .\.dotnet-tools dotnet-ef --version 9.0.11 + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + with: + version: 10.34.5 - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22.x' + cache: 'pnpm' - name: setup java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: distribution: 'microsoft' java-version: '17' - name: install node deps - run: npm install --ignore-scripts --no-audit --no-fund + run: pnpm install --ignore-scripts --frozen-lockfile - name: node build - run: npm run build + run: pnpm run build - name: install dotnet deps run: | dotnet tool install -g dotnet-reportgenerator-globaltool dotnet restore - name: build - run: npm run dotnet:build + run: pnpm run dotnet:build - name: start solr run: ./web/solr/bin/solr start - name: test run: | - npm run test:integrationTests + dotnet test web.Tests/web.Tests.csproj --filter IntegrationTests --no-build -e Demo=True --collect:"XPlat Code Coverage;Format=cobertura" --results-directory TestResults --logger:"junit;LogFilePath=TestResults/integration-tests.junit.xml" env: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -135,6 +192,25 @@ jobs: Write-Host "Coverage file not found, skipping report generation" } + - name: enforce coverage threshold + if: always() + shell: pwsh + env: + COVERAGE_THRESHOLD: '1' + run: | + if (!(Test-Path coverage.cobertura.xml)) { + Write-Host "Coverage file not found, skipping threshold check." + exit 0 + } + + [xml]$coverage = Get-Content coverage.cobertura.xml + $lineRate = [double]$coverage.coverage.'line-rate' * 100 + Write-Host ("Line coverage: {0:N2}%" -f $lineRate) + + if ($lineRate -lt [double]$env:COVERAGE_THRESHOLD) { + throw "Line coverage $lineRate is below threshold $env:COVERAGE_THRESHOLD." + } + - name: upload cov if: always() uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de @@ -144,6 +220,15 @@ jobs: fail_ci_if_error: false verbose: true + - name: upload test results + if: ${{ !cancelled() }} + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./TestResults/integration-tests.junit.xml + report-type: test_results + flags: integration + # browser_tests: # name: 'browser tests' # runs-on: windows-latest @@ -217,31 +302,36 @@ jobs: uses: actions/setup-dotnet@131b410979e0b49e2162c0718030257b22d6dc2c with: dotnet-version: '9.0.x' + - name: setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 + with: + version: 10.34.5 - name: setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '22.x' + cache: 'pnpm' - name: setup java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 with: distribution: 'microsoft' java-version: '17' - name: install node deps - run: npm install --ignore-scripts --no-audit --no-fund + run: pnpm install --ignore-scripts --frozen-lockfile - name: node build - run: npm run build + run: pnpm run build - name: install dotnet deps run: | dotnet tool install -g dotnet-reportgenerator-globaltool dotnet restore - name: build - run: npm run dotnet:build + run: pnpm run dotnet:build - name: start solr run: ./web/solr/bin/solr start - name: test run: | - npm run test:functionTests + dotnet test web.Tests/web.Tests.csproj --filter FunctionTests --no-build -e Demo=True --collect:"XPlat Code Coverage;Format=cobertura" --results-directory TestResults --logger:"junit;LogFilePath=TestResults/function-tests.junit.xml" env: BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -273,6 +363,25 @@ jobs: Write-Host "Coverage file not found, skipping report generation" } + - name: enforce coverage threshold + if: always() + shell: pwsh + env: + COVERAGE_THRESHOLD: '1' + run: | + if (!(Test-Path coverage.cobertura.xml)) { + Write-Host "Coverage file not found, skipping threshold check." + exit 0 + } + + [xml]$coverage = Get-Content coverage.cobertura.xml + $lineRate = [double]$coverage.coverage.'line-rate' * 100 + Write-Host ("Line coverage: {0:N2}%" -f $lineRate) + + if ($lineRate -lt [double]$env:COVERAGE_THRESHOLD) { + throw "Line coverage $lineRate is below threshold $env:COVERAGE_THRESHOLD." + } + - name: upload cov if: always() uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de @@ -281,3 +390,12 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false verbose: true + + - name: upload test results + if: ${{ !cancelled() }} + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./TestResults/function-tests.junit.xml + report-type: test_results + flags: function diff --git a/docker-compose.yml b/docker-compose.yml index 31130c17b..e503e0884 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: sqlserver: - image: mcr.microsoft.com/mssql/server:2025-latest + image: mcr.microsoft.com/mssql/server:2022-latest container_name: atlas_sqlserver environment: ACCEPT_EULA: 'Y' @@ -80,8 +80,8 @@ services: frontend: build: - context: ./frontend - dockerfile: Dockerfile + context: . + dockerfile: frontend/Dockerfile args: API_URL: http://web:${WEB_PORT:-5000} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5000} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c4736ae6a..a43c8b3f1 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,29 +1,33 @@ FROM node:22-alpine AS base +RUN apk add --no-cache git RUN npm install -g pnpm@10.32.1 FROM base AS deps WORKDIR /app -COPY package.json pnpm-lock.yaml ./ -COPY .husky/install.mjs ./.husky/install.mjs -RUN pnpm install --frozen-lockfile +COPY package.json ./package.json +COPY pnpm-lock.yaml ./pnpm-lock.yaml +COPY frontend ./frontend +RUN pnpm install --ignore-scripts --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY package.json ./package.json +COPY frontend ./frontend ARG API_URL ARG NEXT_PUBLIC_API_URL ENV API_URL=$API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL -RUN pnpm build +RUN pnpm frontend:build FROM base AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/frontend/public ./frontend/public +COPY --from=builder --chown=nextjs:nodejs /app/frontend/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/frontend/.next/static ./frontend/.next/static +WORKDIR /app/frontend USER nextjs EXPOSE 3000 ENV PORT=3000 diff --git a/frontend/app/auth/redirect-origin.test.ts b/frontend/app/auth/redirect-origin.test.ts index 7fa0a7c3d..d251b04dd 100644 --- a/frontend/app/auth/redirect-origin.test.ts +++ b/frontend/app/auth/redirect-origin.test.ts @@ -1,4 +1,4 @@ -import type { NextRequest } from "next/server" +import { NextRequest } from "next/server" import { afterEach, describe, expect, test } from "vitest" import { GET as callbackGet } from "./callback/route" import { GET as logoutGet } from "./logout/route" @@ -25,7 +25,7 @@ describe("auth public redirect origin", () => { test("logout redirects to configured public origin", () => { process.env.AUTH_RETURN_URL_ORIGIN = "https://library.atlas.bi" - const response = logoutGet(new Request("https://0.0.0.0:3000/auth/logout") as NextRequest) + const response = logoutGet(new NextRequest("https://0.0.0.0:3000/auth/logout")) expect(response.headers.get("location")).toBe("https://library.atlas.bi/") }) diff --git a/frontend/app/collections/edit/page.tsx b/frontend/app/collections/edit/page.tsx index 4389cf46b..280f25a53 100644 --- a/frontend/app/collections/edit/page.tsx +++ b/frontend/app/collections/edit/page.tsx @@ -17,7 +17,7 @@ function getSingleValue(value: string | string[] | undefined): string | undefine export default async function EditCollectionPage({ searchParams, }: { - searchParams: EditSearchParams + searchParams: Promise }) { const token = await getToken() if (!token) redirect("/auth/login") @@ -43,7 +43,8 @@ export default async function EditCollectionPage({ ) } - const idRaw = getSingleValue(searchParams.id) + const resolvedSearchParams = await searchParams + const idRaw = getSingleValue(resolvedSearchParams.id) const id = idRaw ? Number(idRaw) : NaN if (!Number.isFinite(id) || id <= 0) { return ( diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx index e27317d50..180ab33f4 100644 --- a/frontend/app/global-error.tsx +++ b/frontend/app/global-error.tsx @@ -1,7 +1,6 @@ "use client" -import Link from "next/link" -import { Button } from "@/components/ui/button" +import { useEffect } from "react" import { reportError } from "@/lib/error-reporting" export default function GlobalError({ @@ -11,7 +10,9 @@ export default function GlobalError({ error: Error & { digest?: string } reset: () => void }) { - reportError(error, { boundary: "app/global-error" }) + useEffect(() => { + reportError(error, { boundary: "app/global-error" }) + }, [error]) return ( @@ -23,10 +24,19 @@ export default function GlobalError({ An unexpected error occurred. Please retry or return to the home page.

- - + + + Home +
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index acd65fb19..31edc4fb6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5,6 +5,9 @@ @custom-variant dark (&:is(.dark *)); :root { + --font-inter: "Inter", sans-serif; + --font-source-code-pro: "Source Code Pro", monospace; + --font-rasa: "Rasa", serif; --primary: oklch(0.205 0 0); --success: #48c78e; --warning: #ffe08a; diff --git a/frontend/app/groups/page.tsx b/frontend/app/groups/page.tsx index c24cff281..9e4ee0868 100644 --- a/frontend/app/groups/page.tsx +++ b/frontend/app/groups/page.tsx @@ -1,15 +1,17 @@ import Link from "next/link" import { redirect } from "next/navigation" +import type { Metadata } from "next" import { GroupDetailsTable } from "@/components/groups/group-details-table" import { GroupPageTabs } from "@/components/groups/group-page-tabs" import { GroupReportsTable } from "@/components/groups/group-reports-table" import { GroupRunListPanel } from "@/components/groups/group-run-list-panel" import { GroupUsersTable } from "@/components/groups/group-users-table" import { HomeGroupsPanelView } from "@/components/home/home-groups-panel" +import { LibraryShell } from "@/components/layout/library-shell" import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { getCurrentUser, getToken } from "@/lib/auth" +import { type AuthUser, getCurrentUser, getToken } from "@/lib/auth" import { getUserFriendlyErrorMessage } from "@/lib/errors" import { getGroupById, getGroupReports, getGroupsList, getGroupUsers } from "@/lib/groups/api" import { getHomeUserPageSummary } from "@/lib/home/api" @@ -18,40 +20,94 @@ type GroupsSearchParams = { id?: string } +type ShellProps = { + displayName: string + isSignedIn: boolean + isAdministrator: boolean + adminEnabled: boolean +} + function getSingleValue(value: string | string[] | undefined): string | undefined { if (typeof value === "string") return value if (Array.isArray(value)) return value[0] return undefined } -export default async function GroupsPage({ searchParams }: { searchParams: GroupsSearchParams }) { +function resolveDisplayName(user: AuthUser | null): string { + if (!user) return "Guest" + if (user.fullname && user.fullname !== "Guest") return user.fullname + return user.username || "Guest" +} + +function getShellProps(user: AuthUser | null): ShellProps { + return { + displayName: resolveDisplayName(user), + isSignedIn: !!user, + isAdministrator: !!user && user.roles.includes("Administrator"), + adminEnabled: user?.adminEnabled ?? false, + } +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const resolvedSearchParams = await searchParams + const idRaw = getSingleValue(resolvedSearchParams.id) + + if (!idRaw) { + return { title: "Groups" } + } + + const id = Number(idRaw) + if (!Number.isFinite(id) || id <= 0) { + return { title: "Group not found" } + } + + const result = await getGroupById(id) + return { + title: result.data?.name?.trim() || `Group ${id}`, + } +} + +export default async function GroupsPage({ + searchParams, +}: { + searchParams: Promise +}) { const token = await getToken() if (!token) redirect("/auth/login") - const idRaw = getSingleValue(searchParams.id) + const resolvedSearchParams = await searchParams + const idRaw = getSingleValue(resolvedSearchParams.id) + + const user = await getCurrentUser() + const shellProps = getShellProps(user) if (!idRaw) { - return + return } const id = Number(idRaw) if (!Number.isFinite(id) || id <= 0) { return ( -
-

Group not found

-

Missing or invalid group id.

- -
+ +
+

Group not found

+

Missing or invalid group id.

+ +
+
) } - const [groupResult, usersResult, reportsResult, user] = await Promise.all([ + const [groupResult, usersResult, reportsResult] = await Promise.all([ getGroupById(id), getGroupUsers(id), getGroupReports(id), - getCurrentUser(), ]) const group = groupResult.data @@ -60,31 +116,35 @@ export default async function GroupsPage({ searchParams }: { searchParams: Group const denied = groupResult.error === "forbidden" || groupResult.status === 403 return ( -
- - - - {denied ? "Access denied" : "Unable to load group"} - - - -

- {denied ? "You do not have access to this page." : message} -

- -
-
-
+ +
+ + + + {denied ? "Access denied" : "Unable to load group"} + + + +

+ {denied ? "You do not have access to this page." : message} +

+ +
+
+
+
) } if (group.permissions?.canViewGroups === false) { return ( -
-

You do not have access to this page.

-
+ +
+

You do not have access to this page.

+
+
) } @@ -98,43 +158,45 @@ export default async function GroupsPage({ searchParams }: { searchParams: Group const reports = reportsResult.data ?? [] return ( -
-
-
-
- - Groups - -
-

- {group.name?.trim() || `Group ${group.id}`} -

-
- - } - users={} - reports={} - activity={} - runList={} - analytics={ -
-

- Site analytics for this group are available in the Analytics application. -

- -
- } - /> + +
+
+ + Home + + / + + Groups + +
+

+ {group.name?.trim() || `Group ${group.id}`} +

-
+ + } + users={} + reports={} + activity={} + runList={} + analytics={ +
+

+ Site analytics for this group are available in the Analytics application. +

+ +
+ } + /> + ) } -async function GroupsListView() { +async function GroupsListView({ shellProps }: { shellProps: ShellProps }) { const result = await getGroupsList() const list = result.data @@ -143,28 +205,32 @@ async function GroupsListView() { const denied = result.error === "forbidden" || result.status === 403 return ( -
- - - - {denied ? "Access denied" : "Unable to load groups"} - - - -

- {denied ? "You do not have access to this page." : message} -

-
-
-
+ +
+ + + + {denied ? "Access denied" : "Unable to load groups"} + + + +

+ {denied ? "You do not have access to this page." : message} +

+
+
+
+
) } if (list.permissions?.canViewGroups === false) { return ( -
-

You do not have access to this page.

-
+ +
+

You do not have access to this page.

+
+
) } @@ -182,11 +248,16 @@ async function GroupsListView() { } return ( -
-
-

Groups

-
+ +
+
+ + Home + +
+

Groups

+
-
+ ) } diff --git a/frontend/app/initiatives/edit/page.tsx b/frontend/app/initiatives/edit/page.tsx new file mode 100644 index 000000000..baa2c1481 --- /dev/null +++ b/frontend/app/initiatives/edit/page.tsx @@ -0,0 +1,108 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { LibraryShell } from "@/components/layout/library-shell" +import { Button } from "@/components/ui/button" +import { getCurrentUser, getToken, hasPermission } from "@/lib/auth" +import { getInitiative } from "@/lib/initiatives/api" +import { getUserFriendlyErrorMessage } from "@/lib/errors" +import { InitiativeForm } from "@/components/initiatives/initiative-form" + +type EditInitiativeSearchParams = { + id?: string +} + +function getSingleValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") return value + if (Array.isArray(value)) return value[0] + return undefined +} + +export default async function EditInitiativePage({ + searchParams, +}: { + searchParams: Promise +}) { + const token = await getToken() + if (!token) redirect("/auth/login") + + const currentUser = await getCurrentUser() + const resolvedSearchParams = await searchParams + + const shellProps = { + displayName: currentUser?.fullname || currentUser?.username || "Guest", + isSignedIn: !!currentUser, + isAdministrator: currentUser?.roles.includes("Administrator") ?? false, + adminEnabled: currentUser?.adminEnabled ?? false, + } + + const idRaw = getSingleValue(resolvedSearchParams.id) + if (!idRaw) { + return ( + +

Initiative not found

+

+ Missing or invalid initiative id. +

+ +
+ ) + } + + const id = Number(idRaw) + if (!Number.isFinite(id) || id <= 0) { + return ( + +

Initiative not found

+

+ Invalid initiative id format. +

+ +
+ ) + } + + const detailResult = await getInitiative(id) + const initiative = detailResult.data + + if (!initiative) { + const message = getUserFriendlyErrorMessage("unknown") + return ( + +

Error

+

{message}

+ +
+ ) + } + + // Permission Check: ensure the user has Edit permission for this specific initiative + if (!initiative.canEditInitiative) { + return ( + +

Access Denied

+

+ You do not have permission to edit this initiative. +

+ +
+ ) + } + + return ( + + + + ) +} diff --git a/frontend/app/initiatives/new/page.tsx b/frontend/app/initiatives/new/page.tsx new file mode 100644 index 000000000..8fe1085cb --- /dev/null +++ b/frontend/app/initiatives/new/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { LibraryShell } from "@/components/layout/library-shell" +import { Button } from "@/components/ui/button" +import { getCurrentUser, getToken, hasPermission } from "@/lib/auth" + +import { InitiativeForm } from "@/components/initiatives/initiative-form" + +export default async function NewInitiativePage() { + const token = await getToken() + if (!token) redirect("/auth/login") + + const currentUser = await getCurrentUser() + const canCreateInitiative = !!currentUser && hasPermission(currentUser, "Create Initiative") + + const shellProps = { + displayName: currentUser?.fullname || currentUser?.username || "Guest", + isSignedIn: !!currentUser, + isAdministrator: currentUser?.roles.includes("Administrator") ?? false, + adminEnabled: currentUser?.adminEnabled ?? false, + } + + if (!canCreateInitiative) { + return ( + +

Access Denied

+

+ You do not have permission to create initiatives. +

+ +
+ ) + } + + return ( + +
+

+ Create Initiative +

+ + +
+
+ ) +} diff --git a/frontend/app/initiatives/page.tsx b/frontend/app/initiatives/page.tsx new file mode 100644 index 000000000..b77c87211 --- /dev/null +++ b/frontend/app/initiatives/page.tsx @@ -0,0 +1,140 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { LibraryShell } from "@/components/layout/library-shell" +import { Button } from "@/components/ui/button" +import { InitiativeDetail } from "@/components/initiatives/initiative-detail" +import { InitiativesIndex } from "@/components/initiatives/initiatives-index" +import { getCurrentUser, getToken, hasPermission } from "@/lib/auth" +import { getUserFriendlyErrorMessage } from "@/lib/errors" +import { getInitiative, getInitiatives } from "@/lib/initiatives/api" + +type InitiativesSearchParams = { + id?: string +} + +function getSingleValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") return value + if (Array.isArray(value)) return value[0] + return undefined +} + +function getShellDisplayName( + user: { + fullname?: string | null + username?: string | null + } | null, +) { + return user?.fullname?.trim() || user?.username?.trim() || "Guest" +} + +export default async function InitiativesPage({ + searchParams, +}: { + searchParams: Promise +}) { + const token = await getToken() + if (!token) redirect("/auth/login") + + const currentUser = await getCurrentUser() + const resolvedSearchParams = await searchParams + const idRaw = getSingleValue(resolvedSearchParams.id) + const resolvedId = idRaw ? Number(idRaw) : undefined + + // Detail View + if (resolvedId !== undefined && Number.isFinite(resolvedId) && resolvedId > 0) { + const detailResult = await getInitiative(resolvedId) + const initiative = detailResult.data + + if (!initiative) { + const message = getUserFriendlyErrorMessage("unknown") + const denied = false + + return ( + +
+

+ {denied ? "Access denied" : "Unable to load initiative"} +

+

+ {denied ? "You do not have access to this initiative." : message} +

+ +
+
+ ) + } + + const canViewOtherUser = !!currentUser && hasPermission(currentUser, "View Other User") + const canCreateInitiative = !!currentUser && hasPermission(currentUser, "Create Initiative") + + return ( + +
+ + ← Back to initiatives + +
+ +
+ ) + } + + // Index View + const listResult = await getInitiatives() + const listData = listResult.data + + if (!listData) { + const message = getUserFriendlyErrorMessage("unknown") + const denied = false + + return ( + +
+

+ {denied ? "Access denied" : "Unable to load initiatives"} +

+

+ {denied ? "You do not have access to this page." : message} +

+ +
+
+ ) + } + + const canCreateInitiative = !!currentUser && hasPermission(currentUser, "Create Initiative") + + return ( + + + + ) +} diff --git a/frontend/app/interactions/actions.ts b/frontend/app/interactions/actions.ts index 48c11a6bc..710bdfec6 100644 --- a/frontend/app/interactions/actions.ts +++ b/frontend/app/interactions/actions.ts @@ -24,6 +24,8 @@ export async function toggleStarAction(type: InteractionEntityType, id: number) revalidatePath("/") if (type === "report") { revalidatePath("/reports") + } else if (type === "term") { + revalidatePath("/terms") } else { revalidatePath("/collections") } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 60611280f..ba7e60117 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,27 +1,16 @@ import type { Metadata } from "next" -import { Inter, Rasa, Source_Code_Pro } from "next/font/google" +import "@fontsource/inter/400.css" +import "@fontsource/inter/500.css" +import "@fontsource/inter/600.css" +import "@fontsource/inter/700.css" +import "@fontsource/rasa/600.css" +import "@fontsource/rasa/700.css" +import "@fontsource/source-code-pro/400.css" import { ThemeProvider } from "@/components/theme-provider" import { Toaster } from "@/components/ui/sonner" import { TooltipProvider } from "@/components/ui/tooltip" import "./globals.css" -const inter = Inter({ - variable: "--font-inter", - subsets: ["latin"], - weight: ["400", "500", "600", "700"], -}) - -const sourceCodePro = Source_Code_Pro({ - variable: "--font-source-code-pro", - subsets: ["latin"], -}) - -const rasa = Rasa({ - variable: "--font-rasa", - subsets: ["latin"], - weight: ["600", "700"], -}) - export const metadata: Metadata = { title: { template: "%s | Atlas BI Library", @@ -37,9 +26,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/frontend/app/profile/actions.ts b/frontend/app/profile/actions.ts index 9c90a14c9..a300c8058 100644 --- a/frontend/app/profile/actions.ts +++ b/frontend/app/profile/actions.ts @@ -49,7 +49,9 @@ export async function loadProfileAnalyticsAction( getProfileReports(filters), getProfileFails(filters), getProfileRunList(filters), - canLoadProfileRelationships ? getProfileStars(filters) : Promise.resolve({ data: [], error: null }), + canLoadProfileRelationships + ? getProfileStars(filters) + : Promise.resolve({ data: [], error: null }), canLoadProfileRelationships ? getProfileSubscriptions(filters) : Promise.resolve({ data: [], error: null }), diff --git a/frontend/app/reports/edit/page.tsx b/frontend/app/reports/edit/page.tsx index bc91a8f59..813470b8d 100644 --- a/frontend/app/reports/edit/page.tsx +++ b/frontend/app/reports/edit/page.tsx @@ -15,7 +15,12 @@ function getSingleValue(value: string | string[] | undefined): string | undefine return undefined } -export default async function EditReportPage({ searchParams }: { searchParams: EditSearchParams }) { +export default async function EditReportPage({ + searchParams, +}: { + searchParams: Promise +}) { + const resolvedSearchParams = await searchParams const token = await getToken() if (!token) redirect("/auth/login") @@ -40,7 +45,7 @@ export default async function EditReportPage({ searchParams }: { searchParams: E ) } - const idRaw = getSingleValue(searchParams.id) + const idRaw = getSingleValue(resolvedSearchParams.id) const id = idRaw ? Number(idRaw) : NaN if (!Number.isFinite(id) || id <= 0) { return ( diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index 9be711d0d..b39e5347c 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -146,19 +146,24 @@ function renderHighlightSnippet(snippet: string) { return nodes } -export default async function SearchPage({ searchParams }: { searchParams: SearchPageParams }) { +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise +}) { const token = await getToken() if (!token) redirect("/auth/login") const user = await getCurrentUser() const canUseAdvancedSearch = !!user && hasPermission(user, "Show Advanced Search") + const resolvedSearchParams = await searchParams - const q = getSingleValue(searchParams.q) ?? "" - const type = normalizeSearchType(getSingleValue(searchParams.type)) - const page = asPositiveInt(getSingleValue(searchParams.page), 1) - const pageSize = asPositiveInt(getSingleValue(searchParams.pageSize), 20) - const field = getSingleValue(searchParams.field) - const advancedRequested = getSingleValue(searchParams.advanced) === "Y" + const q = getSingleValue(resolvedSearchParams.q) ?? "" + const type = normalizeSearchType(getSingleValue(resolvedSearchParams.type)) + const page = asPositiveInt(getSingleValue(resolvedSearchParams.page), 1) + const pageSize = asPositiveInt(getSingleValue(resolvedSearchParams.pageSize), 20) + const field = getSingleValue(resolvedSearchParams.field) + const advancedRequested = getSingleValue(resolvedSearchParams.advanced) === "Y" const requestParams: SearchPageParams = { q, @@ -171,7 +176,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Searc if (canUseAdvancedSearch && advancedRequested) requestParams.advanced = "Y" // Preserve all dynamic facet filters by passing every non-reserved param through. - Object.entries(searchParams).forEach(([key, value]) => { + Object.entries(resolvedSearchParams).forEach(([key, value]) => { if (!RESERVED_KEYS.has(key)) { requestParams[key] = value } @@ -283,7 +288,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Searc {data.facets.map((facet) => { - const selected = new Set(getFacetValues(searchParams, facet.key)) + const selected = new Set(getFacetValues(resolvedSearchParams, facet.key)) return ( diff --git a/frontend/app/terms/actions.ts b/frontend/app/terms/actions.ts new file mode 100644 index 000000000..a79c3004c --- /dev/null +++ b/frontend/app/terms/actions.ts @@ -0,0 +1,38 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createTerm, deleteTerm, updateTerm } from "@/lib/terms/api" +import type { TermWriteBody } from "@/lib/terms/types" + +export async function createTermAction( + body: TermWriteBody, +): Promise<{ error: string } | undefined> { + const result = await createTerm(body) + if (!result.ok) { + return { error: result.message } + } + revalidatePath("/terms") + redirect(`/terms?id=${result.data.id}`) +} + +export async function updateTermAction( + id: number, + body: TermWriteBody, +): Promise<{ error: string } | undefined> { + const result = await updateTerm(id, body) + if (!result.ok) { + return { error: result.message } + } + revalidatePath("/terms") + redirect(`/terms?id=${id}`) +} + +export async function deleteTermAction(id: number): Promise<{ error: string } | undefined> { + const result = await deleteTerm(id) + if (!result.ok) { + return { error: result.message } + } + revalidatePath("/terms") + redirect("/terms") +} \ No newline at end of file diff --git a/frontend/app/terms/edit/page.tsx b/frontend/app/terms/edit/page.tsx new file mode 100644 index 000000000..6419e0395 --- /dev/null +++ b/frontend/app/terms/edit/page.tsx @@ -0,0 +1,111 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { LibraryShell } from "@/components/layout/library-shell" +import { TermForm } from "@/components/terms/term-form" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { type AuthUser, getCurrentUser, getToken } from "@/lib/auth" +import { getUserFriendlyErrorMessage } from "@/lib/errors" +import { getTermById } from "@/lib/terms/api" + +type TermEditSearchParams = { + id?: string +} + +function getSingleValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") return value + if (Array.isArray(value)) return value[0] + return undefined +} + +function resolveDisplayName(user: AuthUser | null): string { + if (!user) return "Guest" + if (user.fullname && user.fullname !== "Guest") return user.fullname + return user.username || "Guest" +} + +function getShellProps(user: AuthUser | null) { + return { + displayName: resolveDisplayName(user), + isSignedIn: !!user, + isAdministrator: !!user && user.roles.includes("Administrator"), + adminEnabled: user?.adminEnabled ?? false, + } +} + +export default async function EditTermPage({ + searchParams, +}: { + searchParams: Promise +}) { + const token = await getToken() + if (!token) redirect("/auth/login") + + const resolvedSearchParams = await searchParams + const user = await getCurrentUser() + const shellProps = getShellProps(user) + + const idRaw = getSingleValue(resolvedSearchParams.id) + const id = idRaw ? Number(idRaw) : NaN + if (!Number.isFinite(id) || id <= 0) { + return ( + +

Term not found

+

Missing or invalid term id.

+ +
+ ) + } + + const result = await getTermById(id) + const term = result.data + + if (!term) { + const message = getUserFriendlyErrorMessage(result.error ?? "unknown") + return ( + + + + Unable to load term + + +

{message}

+ +
+
+
+ ) + } + + if (!term.permissions?.canEditTerm) { + return ( + +

Access Denied

+

+ You do not have permission to edit this term. +

+ +
+ ) + } + + return ( + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/terms/new/page.tsx b/frontend/app/terms/new/page.tsx new file mode 100644 index 000000000..8d2134c23 --- /dev/null +++ b/frontend/app/terms/new/page.tsx @@ -0,0 +1,59 @@ +import Link from "next/link" +import { redirect } from "next/navigation" +import { LibraryShell } from "@/components/layout/library-shell" +import { TermForm } from "@/components/terms/term-form" +import { Button } from "@/components/ui/button" +import { type AuthUser, getCurrentUser, getToken } from "@/lib/auth" +import { getTermsList } from "@/lib/terms/api" + +function resolveDisplayName(user: AuthUser | null): string { + if (!user) return "Guest" + if (user.fullname && user.fullname !== "Guest") return user.fullname + return user.username || "Guest" +} + +function getShellProps(user: AuthUser | null) { + return { + displayName: resolveDisplayName(user), + isSignedIn: !!user, + isAdministrator: !!user && user.roles.includes("Administrator"), + adminEnabled: user?.adminEnabled ?? false, + } +} + +export default async function NewTermPage() { + const token = await getToken() + if (!token) redirect("/auth/login") + + const user = await getCurrentUser() + const shellProps = getShellProps(user) + + const listResult = await getTermsList() + const list = listResult.data + + if (!list?.permissions?.canCreateTerm) { + return ( + +

Access Denied

+

+ You do not have permission to create terms. +

+ +
+ ) + } + + return ( + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/terms/page.tsx b/frontend/app/terms/page.tsx new file mode 100644 index 000000000..67f86f966 --- /dev/null +++ b/frontend/app/terms/page.tsx @@ -0,0 +1,271 @@ +import { FileText, BadgeCheck } from "lucide-react" +import Link from "next/link" +import { redirect } from "next/navigation" +import { MarkdownContent } from "@/components/content/markdown-content" +import { TechnicalDefinitionContent } from "@/components/content/technical-definition-content" +import { LibraryShell } from "@/components/layout/library-shell" +import { ProfileAnalyticsPanel } from "@/components/profile/profile-analytics-panel" +import { TermRelatedReportCard } from "@/components/snippets/term-related-report-card" +import { TermActionRail } from "@/components/terms/term-action-rail" +import { TermMetadataTable } from "@/components/terms/term-metadata-table" +import { TermSectionNav } from "@/components/terms/term-section-nav" +import { TermsListCard } from "@/components/terms/terms-list-card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { type AuthUser, getCurrentUser, getToken } from "@/lib/auth" +import { getUserFriendlyErrorMessage } from "@/lib/errors" +import { getTermById, getTermReports, getTermsList } from "@/lib/terms/api" + +type TermsSearchParams = { + id?: string +} + +function getSingleValue(value: string | string[] | undefined): string | undefined { + if (typeof value === "string") return value + if (Array.isArray(value)) return value[0] + return undefined +} + +function resolveDisplayName(user: AuthUser | null): string { + if (!user) return "Guest" + if (user.fullname && user.fullname !== "Guest") return user.fullname + return user.username || "Guest" +} + +function getShellProps(user: AuthUser | null) { + return { + displayName: resolveDisplayName(user), + isSignedIn: !!user, + isAdministrator: !!user && user.roles.includes("Administrator"), + adminEnabled: user?.adminEnabled ?? false, + } +} + +export default async function TermsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const token = await getToken() + if (!token) redirect("/auth/login") + + const resolvedSearchParams = await searchParams + const user = await getCurrentUser() + const shellProps = getShellProps(user) + + const idRaw = getSingleValue(resolvedSearchParams.id) + if (idRaw) { + const id = Number(idRaw) + if (!Number.isFinite(id) || id <= 0) { + return ( + +

Term not found

+

Missing or invalid term id.

+ +
+ ) + } + + const [termResult, reportsResult] = await Promise.all([ + getTermById(id), + getTermReports(id), + ]) + + const term = termResult.data + const reports = reportsResult.data ?? [] + + if (!term) { + const message = getUserFriendlyErrorMessage(termResult.error ?? "unknown") + return ( + + + + Unable to load term + + +

{message}

+ +
+
+
+ ) + } + + const hasSummary = !!term.summary?.trim() + const hasTechnicalDefinition = !!term.technicalDefinition?.trim() + const hasDescription = hasSummary || hasTechnicalDefinition + const hasReports = reports.length > 0 + const features = term.features ?? undefined + + return ( + +
+ + {term.name} + + / + + Terms + + / + + Home + +
+
+
+ + } + /> +
+ +
+
+

+ {term.name} + {term.isApproved ? ( + + Approved + + ) : null} +

+ +
+
+ {(hasSummary || hasTechnicalDefinition) ? ( + <> +

Description

+ {hasSummary ? ( +
+

Summary

+
+ +
+
+ ) : null} + {hasTechnicalDefinition ? ( +
+

Technical Definition

+
+ +
+
+ ) : null} + + ) : null} + + {hasReports ? ( + <> +

Linked Reports

+
+ {reports.map((report) => ( + + ))} +
+ + ) : null} +
+ +
+

Details

+
+ +
+
+
+
+
+ ) + } + + const listResult = await getTermsList() + const list = listResult.data + + if (!list) { + const message = getUserFriendlyErrorMessage(listResult.error ?? "unknown") + const statusHint = + listResult.status != null ? `The API responded with HTTP ${listResult.status}.` : null + const backendHint = + listResult.error === "server_error" + ? "The Terms API on the C# backend returned an error. Check backend logs for GET /api/terms." + : listResult.error === "service_unavailable" + ? "API_URL is not configured. Set API_URL in .env.local to your Library backend." + : null + return ( + + + + Unable to load terms + + +

{message}

+ {statusHint ?

{statusHint}

: null} + {backendHint ?

{backendHint}

: null} + +
+
+
+ ) + } + + return ( + +
+ + Terms + + / + + Home + +
+ +
+

Terms

+ {list.permissions?.canCreateTerm ? ( +
+ +
+ ) : null} +
+ + {list.items.length === 0 ? ( +

No terms found.

+ ) : ( +
+ {list.items.map((item) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index bf721956c..a4cacfc3c 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -30,12 +30,10 @@ function getSingleValue(value: string | string[] | undefined): string | undefine } function getShellDisplayName( - user: - | { - fullname?: string | null - username?: string | null - } - | null, + user: { + fullname?: string | null + username?: string | null + } | null, ) { return user?.fullname?.trim() || user?.username?.trim() || "Guest" } diff --git a/frontend/biome.json b/frontend/biome.json index 0551cdb30..be7381021 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,9 +1,8 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", - "root": true, + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", "files": { "ignoreUnknown": true, - "includes": ["**/*", "!**/.next", "!**/node_modules", "!app/globals.css"] + "includes": ["**/*", "!**/.next", "!**/node_modules", "!app/globals.css", "!public/*.svg"] }, "vcs": { "enabled": false diff --git a/frontend/components/collections/collections-list-card.tsx b/frontend/components/collections/collections-list-card.tsx index afdcf6b55..98bee6513 100644 --- a/frontend/components/collections/collections-list-card.tsx +++ b/frontend/components/collections/collections-list-card.tsx @@ -28,7 +28,6 @@ export function CollectionsListCard({ collection }: { collection: CollectionList
- {/* biome-ignore lint/performance/noImgElement: proxied backend placeholder matches C# _Snippet.cshtml */} {`${collection.name}{children} ), pre: ({ children }: { children?: ReactNode }) => ( -
{children}
+
{children}
), } @@ -39,7 +39,7 @@ export function MarkdownContent({ content, className }: { content: string; class return (
diff --git a/frontend/components/content/markdown-field.tsx b/frontend/components/content/markdown-field.tsx new file mode 100644 index 000000000..e850511c4 --- /dev/null +++ b/frontend/components/content/markdown-field.tsx @@ -0,0 +1,55 @@ +"use client" + +import { useRef } from "react" +import { Label } from "@/components/ui/label" +import { cn } from "@/lib/utils" + +import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror" +import { markdown, markdownLanguage } from "@codemirror/lang-markdown" +import { EditorView } from "@codemirror/view" + +export function MarkdownField({ + id, + label, + value, + onChange, + rows = 8, +}: { + id: string + label: string + value: string + onChange: (value: string) => void + rows?: number +}) { + const editorRef = useRef(null) + + return ( +
+ +
+
+ { + onChange(val) + }} + extensions={[markdown({ base: markdownLanguage }), EditorView.lineWrapping]} + className={cn("text-sm", "[&_.cm-editor.cm-focused]:outline-none", "[&_.cm-scroller]:p-3")} + minHeight="80px" + basicSetup={{ + lineNumbers: false, + foldGutter: false, + highlightActiveLine: false, + highlightActiveLineGutter: false, + bracketMatching: true, + closeBrackets: true, + autocompletion: false, + }} + /> +
+
+
+ ) +} diff --git a/frontend/components/content/technical-definition-content.tsx b/frontend/components/content/technical-definition-content.tsx new file mode 100644 index 000000000..742cac866 --- /dev/null +++ b/frontend/components/content/technical-definition-content.tsx @@ -0,0 +1,5 @@ +import { MarkdownContent } from "@/components/content/markdown-content" + +export function TechnicalDefinitionContent({ content }: { content: string }) { + return +} diff --git a/frontend/components/home/home-stars-panel.test.tsx b/frontend/components/home/home-stars-panel.test.tsx index 85033c093..a1d05fe9f 100644 --- a/frontend/components/home/home-stars-panel.test.tsx +++ b/frontend/components/home/home-stars-panel.test.tsx @@ -1,10 +1,16 @@ import { render, screen } from "@testing-library/react" +import type { ReactNode } from "react" import { describe, expect, it } from "vitest" import { HomeStarsPanelView } from "@/components/home/home-stars-panel" +import { TooltipProvider } from "@/components/ui/tooltip" + +function renderWithTooltipProvider(ui: ReactNode) { + return render({ui}) +} describe("HomeStarsPanelView", () => { it("renders richer card content with tags, image, and footer actions", () => { - render( + renderWithTooltipProvider( { id: 7, href: "/reports?id=7", title: "Executive Dashboard", - typeLabel: "Rpt", + typeLabel: "Report", description: "Leadership reporting summary...", thumbnailUrl: "http://localhost:5000/data/img?handler=Thumb&id=7&size=128x128", tags: [ @@ -47,11 +53,11 @@ describe("HomeStarsPanelView", () => { expect(screen.getByRole("link", { name: "Run report" })).toBeInTheDocument() expect(screen.getByRole("link", { name: "Edit" })).toBeInTheDocument() expect(screen.getByRole("link", { name: "Manage" })).toBeInTheDocument() - expect(screen.getByLabelText("Open report profile")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Open report profile" })).toBeInTheDocument() }) it("renders the plain empty-state message when there are no favorites or suggestions", () => { - render( + renderWithTooltipProvider( { }) it("renders the Razor-style suggestion fallback when suggested reports are present", () => { - render( + renderWithTooltipProvider(
{card.thumbnailUrl || card.placeholderImageUrl ? ( - // biome-ignore lint/performance/noImgElement: homepage parity uses backend-provided report thumbnails directly. {`${card.title} ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + }), +})) + +describe("InitiativeActions", () => { + const baseData: InitiativeDetailDto = { + id: 1, + name: "Test Initiative", + } + + it("renders Edit and Delete when permissions are true", () => { + const data = { ...baseData, canEditInitiative: true, canDeleteInitiative: true } + render() + + expect(screen.getByRole("button", { name: "Edit" })).toBeDefined() + expect(screen.getByRole("button", { name: "Delete" })).toBeDefined() + }) + + it("hides Edit and Delete when permissions are false", () => { + const data = { ...baseData, canEditInitiative: false, canDeleteInitiative: false } + render() + + expect(screen.queryByRole("button", { name: "Edit" })).toBeNull() + expect(screen.queryByRole("button", { name: "Delete" })).toBeNull() + }) + + it("hides Edit and Delete when permissions are missing (undefined)", () => { + // baseData has no permission flags + render() + + expect(screen.queryByRole("button", { name: "Edit" })).toBeNull() + expect(screen.queryByRole("button", { name: "Delete" })).toBeNull() + }) +}) diff --git a/frontend/components/initiatives/initiative-actions.tsx b/frontend/components/initiatives/initiative-actions.tsx new file mode 100644 index 000000000..e240625dd --- /dev/null +++ b/frontend/components/initiatives/initiative-actions.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button" +import { deleteInitiativeAction } from "@/lib/initiatives/actions" +import type { InitiativeDetailDto } from "@/lib/initiatives/types" + +export function InitiativeActions({ data }: { data: InitiativeDetailDto }) { + const router = useRouter() + const [isDeleting, setIsDeleting] = useState(false) + + const handleDelete = async () => { + if (!window.confirm("Are you sure you want to delete this initiative?")) { + return + } + + setIsDeleting(true) + const result = await deleteInitiativeAction(data.id) + setIsDeleting(false) + + if (result.error) { + alert(`Error deleting initiative: ${result.error}`) + } else { + router.push("/initiatives") + router.refresh() + } + } + + const handleEdit = () => { + router.push(`/initiatives/edit?id=${data.id}`) + } + + return ( +
+ {data.canEditInitiative && ( + + )} + {data.canDeleteInitiative && ( + + )} +
+ ) +} diff --git a/frontend/components/initiatives/initiative-detail.test.tsx b/frontend/components/initiatives/initiative-detail.test.tsx new file mode 100644 index 000000000..bb647460c --- /dev/null +++ b/frontend/components/initiatives/initiative-detail.test.tsx @@ -0,0 +1,120 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi, beforeEach } from "vitest" +import type { ReactNode } from "react" +import { TooltipProvider } from "@/components/ui/tooltip" +import type { InitiativeDetailDto } from "@/lib/initiatives/types" +import { InitiativeDetail } from "./initiative-detail" +import { toggleStarAction } from "@/lib/initiatives/actions" + +// Mock the actions module +vi.mock("@/lib/initiatives/actions", () => ({ + deleteInitiativeAction: vi.fn(), + toggleStarAction: vi.fn(), +})) + +// Mock the next/navigation router +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + }), +})) + +const renderDetail = (ui: ReactNode) => render({ui}) + +describe("InitiativeDetail", () => { + const mockData: InitiativeDetailDto = { + id: 1, + name: "Test Initiative", + operationOwner: { id: 10, fullName: "Alice Operator", username: "alice" }, + executiveOwner: { id: 20, fullName: "Bob Exec", username: "bob" }, + lastUpdatedBy: { id: 30, fullName: "Charlie Editor", username: "charlie" }, + } + + it("renders user links when canViewOtherUser is true (default)", () => { + renderDetail() + + // They should be links ( tags) + expect(screen.getByRole("link", { name: "Alice Operator" })).toBeDefined() + expect(screen.getByRole("link", { name: "Bob Exec" })).toBeDefined() + expect(screen.getByRole("link", { name: "Charlie Editor" })).toBeDefined() + }) + + it("renders user plain text when canViewOtherUser is false", () => { + renderDetail() + + // They should NOT be links + expect(screen.queryByRole("link", { name: "Alice Operator" })).toBeNull() + expect(screen.queryByRole("link", { name: "Bob Exec" })).toBeNull() + expect(screen.queryByRole("link", { name: "Charlie Editor" })).toBeNull() + + // The text should still exist on the page + expect(screen.getByText("Alice Operator")).toBeDefined() + expect(screen.getByText("Bob Exec")).toBeDefined() + expect(screen.getByText("Charlie Editor")).toBeDefined() + }) + + it("hides profile links when the API disables user profiles", () => { + renderDetail( + , + ) + + expect(screen.queryByRole("link", { name: "Alice Operator" })).toBeNull() + }) +}) + +describe("InitiativeDetail - Star Toggle", () => { + const mockData: InitiativeDetailDto = { + id: 1, + name: "Test Initiative", + isStarred: false, + starCount: 5, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("applies server values on successful toggle", async () => { + const user = userEvent.setup() + vi.mocked(toggleStarAction).mockResolvedValue({ + data: { type: "Initiative", id: 1, isStarred: true, count: 6 }, + error: null, + }) + + renderDetail() + + const starButton = screen.getByTitle("Star this initiative") + await user.click(starButton) + + // After the action resolves, count should match server response + expect(await screen.findByText("6")).toBeDefined() + }) + + it("reverts UI on failed toggle", async () => { + const user = userEvent.setup() + const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {}) + vi.mocked(toggleStarAction).mockResolvedValue({ + data: null, + error: "service_unavailable", + }) + + renderDetail() + + // Should show initial count of 5 + expect(screen.getByText("5")).toBeDefined() + + const starButton = screen.getByTitle("Star this initiative") + await user.click(starButton) + + // After error, should revert back to 5 + expect(await screen.findByText("5")).toBeDefined() + expect(alertMock).toHaveBeenCalledWith("Error updating star: service_unavailable") + + alertMock.mockRestore() + }) +}) diff --git a/frontend/components/initiatives/initiative-detail.tsx b/frontend/components/initiatives/initiative-detail.tsx new file mode 100644 index 000000000..1273627f6 --- /dev/null +++ b/frontend/components/initiatives/initiative-detail.tsx @@ -0,0 +1,399 @@ +"use client" + +import { BarChart2, Edit, Plus, Share, Star, Trash2 } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" +import { EntityFeedbackDialog } from "@/components/interactions/entity-feedback-dialog" +import { + deleteInitiativeAction, + toggleStarAction, +} from "@/lib/initiatives/actions" +import type { InitiativeDetailDto } from "@/lib/initiatives/types" +import { ShareModal } from "./share-modal" + +interface InitiativeDetailProps { + data: InitiativeDetailDto + canViewOtherUser?: boolean + canCreateInitiative?: boolean +} + +export function InitiativeDetail({ + data, + canViewOtherUser = true, + canCreateInitiative = false, +}: InitiativeDetailProps) { + const router = useRouter() + + const { + id, + name, + description, + operationOwner, + executiveOwner, + financialImpact, + strategicImportance, + lastUpdatedBy, + lastModifiedDisplay, + collections, + canEditInitiative, + canDeleteInitiative, + features, + canViewUserProfiles, + } = data + + const [isStarred, setIsStarred] = useState(data.isStarred ?? false) + const [starCount, setStarCount] = useState(data.starCount ?? 0) + const [isDeleting, setIsDeleting] = useState(false) + const [shareModalOpen, setShareModalOpen] = useState(false) + + const [isPending, startTransition] = useTransition() + const canViewProfiles = + canViewOtherUser && canViewUserProfiles !== false && features?.userProfilesEnabled !== false + + const toggleStar = () => { + const previousIsStarred = isStarred + const previousStarCount = starCount + + setIsStarred(!previousIsStarred) + setStarCount((prev) => (previousIsStarred ? Math.max(0, prev - 1) : prev + 1)) + + startTransition(() => { + void (async () => { + const result = await toggleStarAction(id) + if (result.error) { + setIsStarred(previousIsStarred) + setStarCount(previousStarCount) + alert(`Error updating star: ${result.error}`) + } else if (result.data) { + setIsStarred(result.data.isStarred) + setStarCount(result.data.count) + } + })() + }) + } + + const handleDelete = async () => { + if (!window.confirm("Are you sure you want to delete this initiative?")) { + return + } + setIsDeleting(true) + const result = await deleteInitiativeAction(id) + setIsDeleting(false) + if (result.error) { + alert(`Error deleting initiative: ${result.error}`) + } else { + router.push("/initiatives") + router.refresh() + } + } + + const handleEdit = () => { + router.push(`/initiatives/edit?id=${id}`) + } + + const handleShare = () => { + setShareModalOpen(true) + } + + return ( +
+ {/* Left Action Rail */} +
+ {/* Star Button */} + + + {/* Share Button */} + {features?.sharingEnabled !== false && ( + + )} + + {/* Create Button */} + {canCreateInitiative && ( + + + + )} + + {/* Edit Button */} + {canEditInitiative && ( + + )} + + {/* Delete Button */} + {canDeleteInitiative && ( + + )} +
+ + {/* Main Content Area */} +
+

+ {name} +

+ +
+ + {description && ( +
+

+ Description +

+
+ {description} +
+
+ )} + +
+

+ Details +

+
+ + + {operationOwner && ( + + + + + )} + {executiveOwner && ( + + + + + )} + {financialImpact && ( + + + + + )} + {strategicImportance && ( + + + + + )} + {lastUpdatedBy && ( + + + + + )} + {lastModifiedDisplay && ( + + + + + )} + +
Operational Owner + {canViewProfiles ? ( + + {operationOwner.fullName || operationOwner.username} + + ) : ( + <>{operationOwner.fullName || operationOwner.username} + )} +
Executive Owner + {canViewProfiles ? ( + + {executiveOwner.fullName || executiveOwner.username} + + ) : ( + <>{executiveOwner.fullName || executiveOwner.username} + )} +
Financial Impact{financialImpact.name}
Strategic Importance{strategicImportance.name}
Last Updated By + {canViewProfiles ? ( + + {lastUpdatedBy.fullName || lastUpdatedBy.username} + + ) : ( + <>{lastUpdatedBy.fullName || lastUpdatedBy.username} + )} +
Last Updated{lastModifiedDisplay}
+
+
+ + {collections && collections.length > 0 && ( +
+

+ Linked Collections +

+
+ {collections.map((c) => ( +
+ {/* Card Header */} +
+ + {c.name} + +
+ + Collection + +
+
+ + {/* Card Body */} +
+
+
+ + + {`${c.name} + +
+
+ +

+ {c.description ? ( + <> + {c.description}{" "} + + read more + + + ) : ( + + Open to view details. + + )} +

+ +
+
+
+ + {/* Card Footer */} +
+
+ +
+
+ + {features?.sharingEnabled !== false && ( + + )} +
+
+
+ ))} +
+
+ )} + + {features?.feedbackEnabled !== false && ( + + )} +
+ + setShareModalOpen(false)} + initiativeName={name} + initiativeId={id} + /> +
+ ) +} diff --git a/frontend/components/initiatives/initiative-form.tsx b/frontend/components/initiatives/initiative-form.tsx new file mode 100644 index 000000000..02df06360 --- /dev/null +++ b/frontend/components/initiatives/initiative-form.tsx @@ -0,0 +1,360 @@ +"use client" + +import { + ArrowLeft, + ArrowRight, + Bold, + Code, + Eye, + Heading, + Italic, + Link as LinkIcon, + List, + Quote, + X, +} from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useState, useTransition } from "react" +import { createInitiativeAction, updateInitiativeAction } from "@/lib/initiatives/actions" +import type { InitiativeDetailDto, InitiativeWriteBody } from "@/lib/initiatives/types" +import { LinkedCollectionPicker } from "./linked-collection-picker" + +export function InitiativeForm({ + mode, + initial, + cancelHref, +}: { + mode: "create" | "edit" + initial?: InitiativeDetailDto | null + cancelHref: string +}) { + const router = useRouter() + const [name, setName] = useState(initial?.name ?? "") + const [description, setDescription] = useState(initial?.description ?? "") + + // Mock states for the new fields based on screenshot + const [operationOwner, setOperationOwner] = useState( + initial?.operationOwner + ? { + id: initial.operationOwner.id, + name: initial.operationOwner.fullName || initial.operationOwner.username, + } + : null, + ) + const [executiveOwner, setExecutiveOwner] = useState( + initial?.executiveOwner + ? { + id: initial.executiveOwner.id, + name: initial.executiveOwner.fullName || initial.executiveOwner.username, + } + : null, + ) + const [financialImpact, setFinancialImpact] = useState(initial?.financialImpact ?? null) + const [strategicImportance, setStrategicImportance] = useState( + initial?.strategicImportance ?? null, + ) + const [selectedCollections, setSelectedCollections] = useState( + initial?.collections ?? [], + ) + + const [formError, setFormError] = useState(null) + const [isPending, startTransition] = useTransition() + + const submit = () => { + setFormError(null) + const trimmedName = name.trim() + if (!trimmedName) { + setFormError("Name is required.") + return + } + + const body: InitiativeWriteBody = { + name: trimmedName, + description: description.trim() ? description.trim() : null, + purpose: null, // Legacy Razor form doesn't seem to have a purpose field in the screenshot + collectionIds: selectedCollections.map((collection) => collection.id), + operationOwnerId: operationOwner?.id ?? null, + executiveOwnerId: executiveOwner?.id ?? null, + financialImpactId: financialImpact?.id ?? null, + strategicImportanceId: strategicImportance?.id ?? null, + hidden: initial?.hidden ?? null, + } + + startTransition(() => { + void (async () => { + if (mode === "create") { + const result = await createInitiativeAction(body) + if (result?.error) { + setFormError(result.error) + } else if (result?.data?.id) { + router.push(`/initiatives?id=${result.data.id}`) + } + return + } + + if (initial?.id) { + const result = await updateInitiativeAction(initial.id, body) + if (result?.error) { + setFormError(result.error) + } else if (result?.data?.id) { + router.push(`/initiatives?id=${result.data.id}`) + } + } + })() + }) + } + + return ( +
+

+ {mode === "create" ? "New Initiative" : "Edit Initiative"} +

+ +
+ +
+ + + Cancel +
+ Go Back +
+
+ + +
+ + {formError && ( +
{formError}
+ )} + +
+ +
+ setName(e.target.value)} + disabled={isPending} + className="w-full rounded-[4px] border border-[#dbdbdb] px-3 py-2 text-[1rem] text-[#363636] shadow-[inset_0_0.0625em_0.125em_rgba(10,10,10,0.05)] focus:border-[#3273dc] focus:outline-none" + placeholder="e.g Data Sorting" + /> +
+
+ +
+ +
+ {/* WYSIWYG Toolbar matching the Share Modal / screenshot */} +
+ + + + + + + +
+ +
+