Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions frontend/app/settings/actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, test, vi } from "vitest"

vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}))

vi.mock("@/lib/settings/api", () => ({
addSiteMessage: vi.fn(),
deleteSiteMessage: vi.fn(),
updateEtl: vi.fn(),
updateTheme: vi.fn(),
updateSearchVisibility: vi.fn(),
createRole: vi.fn(),
updateRolePermission: vi.fn(),
}))

import { revalidatePath } from "next/cache"
import {
addSiteMessage,
createRole,
updateRolePermission,
updateSearchVisibility,
} from "@/lib/settings/api"
import {
addSiteMessageAction,
createRoleAction,
updateRolePermissionAction,
updateSearchVisibilityAction,
} from "./actions"

describe("settings actions", () => {
beforeEach(() => {
vi.clearAllMocks()
})

test("addSiteMessageAction returns data and revalidates on success", async () => {
vi.mocked(addSiteMessage).mockResolvedValueOnce({
ok: true,
data: { id: 1, value: "Hello", description: null },
})

const result = await addSiteMessageAction({ value: "Hello" })

expect(result).toEqual({ data: { id: 1, value: "Hello", description: null } })
expect(revalidatePath).toHaveBeenCalledWith("/settings")
})

test("addSiteMessageAction returns error without revalidating on failure", async () => {
vi.mocked(addSiteMessage).mockResolvedValueOnce({
ok: false,
message: "You do not have permission to view this content.",
code: "forbidden",
})

const result = await addSiteMessageAction({ value: "Hello" })

expect(result).toEqual({ error: "You do not have permission to view this content." })
expect(revalidatePath).not.toHaveBeenCalled()
})

test("updateSearchVisibilityAction delegates to api layer", async () => {
vi.mocked(updateSearchVisibility).mockResolvedValueOnce({ ok: true, data: undefined })

const result = await updateSearchVisibilityAction("users", false)

expect(updateSearchVisibility).toHaveBeenCalledWith("users", false, undefined)
expect(result).toEqual({ data: {} })
expect(revalidatePath).toHaveBeenCalledWith("/settings")
})

test("createRoleAction returns validation error from api", async () => {
vi.mocked(createRole).mockResolvedValueOnce({
ok: false,
message: "Name is required.",
code: "bad_request",
})

const result = await createRoleAction({ name: "" })

expect(result).toEqual({ error: "Name is required." })
})

test("updateRolePermissionAction returns forbidden error", async () => {
vi.mocked(updateRolePermission).mockResolvedValueOnce({
ok: false,
message: "You do not have permission to view this content.",
code: "forbidden",
})

const result = await updateRolePermissionAction(2, 4, true)

expect(result).toEqual({ error: "You do not have permission to view this content." })
expect(revalidatePath).not.toHaveBeenCalled()
})
})
63 changes: 54 additions & 9 deletions frontend/app/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,64 @@ export default async function SettingsLayout({ children }: { children: ReactNode
const canEditRoles = !!user && hasPermission(user, "Edit Role Permissions")
const canEditUsers = !!user && hasPermission(user, "Edit User Permissions")
const canEditGroups = !!user && hasPermission(user, "Edit Group Permissions")
const canManageMetaFields =
!!user && (hasPermission(user, "Create Parameters") || hasPermission(user, "Delete Parameters"))
const canManageSiteSettings = !!user && hasPermission(user, "Manage Global Site Settings")

const navItems = [
{ href: "#roles", label: "Role Configuration", icon: "fa-lock", show: canEditRoles },
{ href: "#user-roles", label: "User Roles", icon: "fa-user-lock", show: canEditUsers },
{ href: "#user-groups", label: "Group Roles", icon: "fa-users", show: canEditGroups },
{ href: "#meta-fields", label: "Meta Fields", icon: "fa-list-ul", show: true },
{ href: "#site-message", label: "Site Message", icon: "fa-comment", show: true },
{ href: "#search", label: "Search", icon: "fa-search", show: true },
{ href: "#theme", label: "Theme", icon: "fa-palette", show: true },
{ href: "#etl", label: "ETL", icon: "fa-database", show: true },
{
id: "roles",
href: "#roles",
label: "Role Configuration",
icon: "fa-lock",
show: canEditRoles,
},
{
id: "user-roles",
href: "#user-roles",
label: "User Roles",
icon: "fa-user-lock",
show: canEditUsers,
},
{
id: "user-groups",
href: "#user-groups",
label: "Group Roles",
icon: "fa-users",
show: canEditGroups,
},
{
id: "meta-fields",
href: "#meta-fields",
label: "Meta Fields",
icon: "fa-list-ul",
show: canManageMetaFields,
},
{
id: "site-message",
href: "#site-message",
label: "Site Message",
icon: "fa-comment",
show: canManageSiteSettings,
},
{
id: "search",
href: "#search",
label: "Search",
icon: "fa-search",
show: canManageSiteSettings,
},
{
id: "theme",
href: "#theme",
label: "Theme",
icon: "fa-palette",
show: canManageSiteSettings,
},
{ id: "etl", href: "#etl", label: "ETL", icon: "fa-database", show: canManageSiteSettings },
].filter((item) => item.show)

const defaultTab = canEditRoles ? "roles" : canEditUsers ? "user-roles" : "meta-fields"
const defaultTab = navItems[0]?.id ?? "roles"

return (
<LibraryShell
Expand Down
13 changes: 13 additions & 0 deletions frontend/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

export const metadata: Metadata = { title: "Settings" }

export default async function SettingsPage() {

Check failure on line 24 in frontend/app/settings/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 39 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=atlas-bi_atlas-bi-library&issues=AaBzsc2Yy_01LXV_fLf3&open=AaBzsc2Yy_01LXV_fLf3&pullRequest=808
const [
messagesResult,
etlResult,
Expand Down Expand Up @@ -62,6 +62,18 @@
getTags("tags"),
])

const tagLoadErrors = [
!orgValues.ok && orgValues.message,
!runFreqs.ok && runFreqs.message,
!frags.ok && frags.message,
!fragTags.ok && fragTags.message,
!maintSchedules.ok && maintSchedules.message,
!maintStatuses.ok && maintStatuses.message,
!finImpacts.ok && finImpacts.message,
!stratImps.ok && stratImps.message,
!tags.ok && tags.message,
].filter((message): message is string => Boolean(message))

return (
<>
{/* Tab panels — the SettingsTabController client component handles visibility via 'hidden' */}
Expand Down Expand Up @@ -102,6 +114,7 @@
</div>

<div id="meta-fields" className="panel-tab-data hidden">
{tagLoadErrors.length > 0 && <p className="text-red-500 mb-4">{tagLoadErrors.join(" ")}</p>}
<TagsSettingsPanel
organizationalValues={orgValues.ok ? orgValues.data : []}
estimatedRunFrequencies={runFreqs.ok ? runFreqs.data : []}
Expand Down
95 changes: 95 additions & 0 deletions frontend/components/settings/etl-theme-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { updateEtlAction, updateThemeAction } from "@/app/settings/actions"
import { EtlThemePanel } from "./etl-theme-panel"

vi.mock("@/app/settings/actions", () => ({
updateEtlAction: vi.fn(),
updateThemeAction: vi.fn(),
}))

describe("EtlThemePanel", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("saves ETL script successfully", async () => {
const user = userEvent.setup()
vi.mocked(updateEtlAction).mockResolvedValueOnce({ data: {} })

render(
<EtlThemePanel
initialEtl="SELECT 1"
initialTheme={null}
defaultEtl="SELECT default"
etlOnly
/>,
)

await user.click(screen.getByRole("button", { name: /save etl/i }))

await waitFor(() => {
expect(updateEtlAction).toHaveBeenCalledWith("SELECT 1")
expect(screen.getByText("ETL script saved.")).toBeInTheDocument()
})
})

it("shows forbidden error when ETL save fails", async () => {
const user = userEvent.setup()
vi.mocked(updateEtlAction).mockResolvedValueOnce({
error: "You do not have permission to view this content.",
})

render(<EtlThemePanel initialEtl="SELECT 1" initialTheme={null} defaultEtl={null} etlOnly />)

await user.click(screen.getByRole("button", { name: /save etl/i }))

await waitFor(() => {
expect(
screen.getByText("You do not have permission to view this content."),
).toBeInTheDocument()
})
})

it("restores default ETL into the textarea", async () => {
const user = userEvent.setup()

render(
<EtlThemePanel
initialEtl="custom sql"
initialTheme={null}
defaultEtl="SELECT default"
etlOnly
/>,
)

const textarea = screen.getByLabelText(/sql script/i)
expect(textarea).toHaveValue("custom sql")

await user.click(screen.getByRole("button", { name: /restore default/i }))

expect(textarea).toHaveValue("SELECT default")
})

it("saves theme CSS successfully", async () => {
const user = userEvent.setup()
vi.mocked(updateThemeAction).mockResolvedValueOnce({ data: {} })

render(
<EtlThemePanel
initialEtl={null}
initialTheme="body { color: red; }"
defaultEtl={null}
themeOnly
/>,
)

await user.click(screen.getByRole("button", { name: /save theme/i }))

await waitFor(() => {
expect(updateThemeAction).toHaveBeenCalledWith("body { color: red; }")
expect(screen.getByText("Theme saved.")).toBeInTheDocument()
})
})
})
83 changes: 83 additions & 0 deletions frontend/components/settings/group-roles-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { removeGroupRoleAction } from "@/app/settings/actions"
import { GroupRolesPanel } from "./group-roles-panel"

vi.mock("@/app/settings/actions", () => ({
addGroupRoleAction: vi.fn(),
removeGroupRoleAction: vi.fn(),
searchSettingsGroupsAction: vi.fn(),
}))

vi.mock("@/components/settings/settings-typeahead", () => ({
SettingsTypeahead: () => <div data-testid="group-typeahead" />,
}))

const AVAILABLE_ROLES = [
{ id: 1, name: "Administrator", permissions: [] },
{ id: 10, name: "Manager", permissions: [] },
]

const INITIAL_ASSIGNMENTS = [
{ groupId: 5, name: "Finance Team", roles: [{ id: 10, name: "Manager" }] },
]

describe("GroupRolesPanel", () => {
beforeEach(() => {
vi.clearAllMocks()
})

it("renders privileged groups", () => {
render(
<GroupRolesPanel initialAssignments={INITIAL_ASSIGNMENTS} availableRoles={AVAILABLE_ROLES} />,
)
expect(screen.getByText("Finance Team")).toBeInTheDocument()
expect(screen.getAllByText("Manager").length).toBeGreaterThan(0)
})

it("shows validation error when group and role are missing", async () => {
const user = userEvent.setup()

render(<GroupRolesPanel initialAssignments={[]} availableRoles={AVAILABLE_ROLES} />)

await user.click(screen.getByRole("button", { name: /^save$/i }))

expect(screen.getByText("Group and role are required.")).toBeInTheDocument()
})

it("shows forbidden error when remove fails", async () => {
const user = userEvent.setup()
vi.mocked(removeGroupRoleAction).mockResolvedValueOnce({
error: "You do not have permission to view this content.",
})

render(
<GroupRolesPanel initialAssignments={INITIAL_ASSIGNMENTS} availableRoles={AVAILABLE_ROLES} />,
)

await user.click(screen.getByRole("button", { name: /remove role manager from finance team/i }))

await waitFor(() => {
expect(
screen.getByText("You do not have permission to view this content."),
).toBeInTheDocument()
})
})

it("removes a group role successfully", async () => {
const user = userEvent.setup()
vi.mocked(removeGroupRoleAction).mockResolvedValueOnce({ data: {} })

render(
<GroupRolesPanel initialAssignments={INITIAL_ASSIGNMENTS} availableRoles={AVAILABLE_ROLES} />,
)

await user.click(screen.getByRole("button", { name: /remove role manager from finance team/i }))

await waitFor(() => {
expect(removeGroupRoleAction).toHaveBeenCalledWith(5, 10)
expect(screen.queryByText("Finance Team")).not.toBeInTheDocument()
})
})
})
Loading
Loading