Skip to content
Closed
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
5 changes: 5 additions & 0 deletions app/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,11 @@ export const wrapQueryClient = <A extends ApiClient>(api: A, queryClient: QueryC
queryClient.invalidateQueries({ queryKey: [method], ...filters }),
setQueryData: <M extends keyof A>(method: M, params: Params<A[M]>, data: Result<A[M]>) =>
queryClient.setQueryData([method, params], data),
setQueryDataErrorsAllowed: <M extends keyof A>(
method: M,
params: Params<A[M]>,
data: ErrorsAllowed<Result<A[M]>, ApiError>
) => queryClient.setQueryData([method, params, ERRORS_ALLOWED], data),
fetchQuery: <M extends string & keyof A>(
method: M,
params: Params<A[M]>,
Expand Down
34 changes: 0 additions & 34 deletions app/components/AccessNameCell.tsx

This file was deleted.

40 changes: 22 additions & 18 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
import { NavLink } from 'react-router-dom'
import { NavLink, useLocation } from 'react-router-dom'

import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react'

Expand Down Expand Up @@ -88,20 +88,24 @@ export const NavLinkItem = (props: {
children: React.ReactNode
end?: boolean
disabled?: boolean
}) => (
<li>
<NavLink
to={props.to}
className={({ isActive }) =>
cn(linkStyles, {
'text-accent !bg-accent-secondary hover:!bg-accent-secondary-hover svg:!text-accent-tertiary':
isActive,
'pointer-events-none text-disabled': props.disabled,
})
}
end={props.end}
>
{props.children}
</NavLink>
</li>
)
}) => {
// If the current page's URL matches the create form for this NavLink resource, we want to highlight the NavLink in the sidebar.
const currentPathIsCreateForm = useLocation().pathname === `${props.to}-new`
return (
<li>
<NavLink
to={props.to}
className={({ isActive }) =>
cn(linkStyles, {
'text-accent !bg-accent-secondary hover:!bg-accent-secondary-hover svg:!text-accent-tertiary':
isActive || currentPathIsCreateForm,
'pointer-events-none text-disabled': props.disabled,
})
}
end={props.end}
>
{props.children}
</NavLink>
</li>
)
}
8 changes: 6 additions & 2 deletions app/pages/SiloAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
} from '@oxide/api'
import { Access24Icon } from '@oxide/design-system/icons/react'

import { AccessNameCell } from '~/components/AccessNameCell'
import { HL } from '~/components/HL'
import { RoleBadgeCell } from '~/components/RoleBadgeCell'
import {
Expand All @@ -36,6 +35,7 @@ import { Button } from '~/ui/lib/Button'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
import { accessTypeLabel } from '~/util/access'
import { groupBy, isTruthy } from '~/util/array'

const EmptyState = ({ onClick }: { onClick: () => void }) => (
Expand Down Expand Up @@ -111,7 +111,11 @@ export function SiloAccessPage() {

const columns = useMemo(
() => [
colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }),
colHelper.accessor('name', { header: 'Name' }),
colHelper.accessor('identityType', {
header: 'Type',
cell: (props) => accessTypeLabel(props.getValue()),
}),
colHelper.accessor('siloRole', {
header: 'Silo role',
cell: RoleBadgeCell,
Expand Down
8 changes: 6 additions & 2 deletions app/pages/project/access/ProjectAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
} from '@oxide/api'
import { Access24Icon } from '@oxide/design-system/icons/react'

import { AccessNameCell } from '~/components/AccessNameCell'
import { HL } from '~/components/HL'
import { RoleBadgeCell } from '~/components/RoleBadgeCell'
import {
Expand All @@ -39,6 +38,7 @@ import { Button } from '~/ui/lib/Button'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
import { accessTypeLabel } from '~/util/access'
import { groupBy, isTruthy } from '~/util/array'

const EmptyState = ({ onClick }: { onClick: () => void }) => (
Expand Down Expand Up @@ -127,7 +127,11 @@ export function ProjectAccessPage() {

const columns = useMemo(
() => [
colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }),
colHelper.accessor('name', { header: 'Name' }),
colHelper.accessor('identityType', {
header: 'Type',
cell: (props) => accessTypeLabel(props.getValue()),
}),
colHelper.accessor('siloRole', {
header: 'Silo role',
cell: RoleBadgeCell,
Expand Down
24 changes: 21 additions & 3 deletions app/pages/project/disks/DisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,27 @@ const EmptyState = () => (
)

DisksPage.loader = async ({ params }: LoaderFunctionArgs) => {
await apiQueryClient.prefetchQuery('diskList', {
query: { ...getProjectSelector(params), limit: 25 },
})
const { project } = getProjectSelector(params)
await Promise.all([
apiQueryClient.prefetchQuery('diskList', {
query: { project, limit: 25 },
}),

// fetch instances and preload into RQ cache so fetches by ID in
// InstanceLinkCell can be mostly instant yet gracefully fall back to
// fetching individually if we don't fetch them all here
apiQueryClient
.fetchQuery('instanceList', { query: { project, limit: 200 } })
.then((instances) => {
for (const instance of instances.items) {
apiQueryClient.setQueryData(
'instanceView',
{ path: { instance: instance.id } },
instance
)
}
}),
])
return null
}

Expand Down
29 changes: 26 additions & 3 deletions app/pages/project/snapshots/SnapshotsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,32 @@ const EmptyState = () => (
)

SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => {
await apiQueryClient.prefetchQuery('snapshotList', {
query: { ...getProjectSelector(params), limit: 25 },
})
const { project } = getProjectSelector(params)
await Promise.all([
apiQueryClient.prefetchQuery('snapshotList', {
query: { project, limit: 25 },
}),

// Fetch disks and preload into RQ cache so fetches by ID in DiskNameFromId
// can be mostly instant yet gracefully fall back to fetching individually
// if we don't fetch them all here. This has to be the *ErrorsAllowed
// version of setQueryData because the disk fetchs are also the errors
// allowed version. If we use regular setQueryData, nothing blows up; the
// data is just never found in the cache. Note that the disks that error
// (delete disks) are not prefetched here because they are (obviously) not
// in the disk list response.
apiQueryClient
.fetchQuery('diskList', { query: { project, limit: 200 } })
.then((disks) => {
for (const disk of disks.items) {
apiQueryClient.setQueryDataErrorsAllowed(
'diskView',
{ path: { disk: disk.id } },
{ type: 'success', data: disk }
)
}
}),
])
return null
}

Expand Down
9 changes: 9 additions & 0 deletions app/pages/system/networking/IpPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) {
path: { pool },
query: { limit: 25 }, // match QueryTable
}),

// fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId
// can be mostly instant yet gracefully fall back to fetching individually
// if we don't fetch them all here
apiQueryClient.fetchQuery('siloList', { query: { limit: 200 } }).then((silos) => {
for (const silo of silos.items) {
apiQueryClient.setQueryData('siloView', { path: { silo: silo.id } }, silo)
}
}),
])
return null
}
Expand Down
15 changes: 15 additions & 0 deletions app/util/access.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { expect, test } from 'vitest'

import { accessTypeLabel } from './access'

test('accessTypeLabel', () => {
expect(accessTypeLabel('silo_group')).toEqual('Group')
expect(accessTypeLabel('silo_user')).toEqual('User')
})
11 changes: 11 additions & 0 deletions app/util/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import type { IdentityType } from '~/api'

export const accessTypeLabel = (identityType: IdentityType) =>
identityType === 'silo_group' ? 'Group' : 'User'
8 changes: 6 additions & 2 deletions test/e2e/project-access.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,26 @@ test('Click through project access page', async ({ page }) => {
const table = page.locator('table')
await expectRowVisible(table, {
Name: 'Hannah Arendt',
Type: 'User',
'Silo role': 'admin',
'Project role': '',
})
await expectRowVisible(table, {
Name: 'Jacob Klein',
Type: 'User',
'Silo role': '',
'Project role': 'collaborator',
})
await expectRowVisible(table, {
// no space because expectRowVisible uses textContent, not accessible name
Name: 'real-estate-devsGroup',
Name: 'real-estate-devs',
Type: 'Group',
'Silo role': 'collaborator',
})
await expectRowVisible(table, {
// no space because expectRowVisible uses textContent, not accessible name
Name: 'kernel-devsGroup',
Name: 'kernel-devs',
Type: 'Group',
'Silo role': '',
'Project role': 'viewer',
})
Expand Down
4 changes: 3 additions & 1 deletion test/e2e/silo-access.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ test('Click through silo access page', async ({ page }) => {
await expectVisible(page, ['role=heading[name*="Access & IAM"]'])
await expectRowVisible(table, {
// no space because expectRowVisible uses textContent, not accessible name
Name: 'real-estate-devsGroup',
Name: 'real-estate-devs',
Type: 'Group',
'Silo role': 'collaborator',
})
await expectRowVisible(table, {
Name: 'Hannah Arendt',
Type: 'User',
'Silo role': 'admin',
})
await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`])
Expand Down