Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7c6a2d5
fix(athena): honor ui.athena.defaultWorkgroup for ?bucket=-scoped con…
smkohnstamm Aug 25, 2026
cff8067
fix(search): stable ordering-control count, accessible Order-by name
smkohnstamm Aug 25, 2026
22e7742
fix(queries): name the QuerySelect field, blank it on load error
smkohnstamm Aug 25, 2026
c051822
fix(admin): explain the disabled Enabled switch, gate roles on isServ…
smkohnstamm Aug 25, 2026
9dfc25d
fix(data-products): load the fixture adapter lazily
smkohnstamm Aug 25, 2026
5c8babe
docs(admin): say what the isService gate actually does
sir-sigurd Aug 26, 2026
2beda22
fix(admin): explain the disabled Admin switch too
sir-sigurd Aug 26, 2026
bc73eb9
review: apply the /code-review findings
smkohnstamm Aug 26, 2026
bb8d5d5
Merge remote-tracking branch 'origin/master' into fix/release-hardeni…
smkohnstamm Aug 26, 2026
b0b4351
fix(athena): keep the ?bucket= scope on in-console links
smkohnstamm Aug 26, 2026
e4e37a2
fix(data-products): degrade instead of latching a failed adapter load
smkohnstamm Aug 26, 2026
2d951b3
fix(admin): reach the disabled-switch reason by keyboard, gate Role o…
smkohnstamm Aug 26, 2026
b36dfec
fix(search): stop the facet sort control flickering as the list narrows
smkohnstamm Aug 26, 2026
4b36a13
fix(athena): pass the default workgroup as a string, not the prefs ob…
smkohnstamm Aug 26, 2026
4b5efb2
test(athena): build the prefs fixture through extendDefaults
smkohnstamm Aug 26, 2026
53cd485
fix(queries): carry ?bucket= on the legacy workgroup and execution re…
smkohnstamm Aug 26, 2026
537b778
docs(data-products): drop the useAdapter tombstone
smkohnstamm Aug 26, 2026
491639d
docs(changelog): match the #5217 entries to what the fixes now cover
smkohnstamm Aug 26, 2026
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 catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ complete sentence without it.

## Changes

- [Fixed] Athena Queries: a console opened with a `?bucket=` scope — which every legacy per-bucket queries URL redirects to — honors that bucket's `ui.athena.defaultWorkgroup` again, and keeps that scope when you follow an execution or breadcrumb link inside the console; the workspace re-home had silently dropped the preference, landing customers on the alphabetically-first workgroup ([#5217](https://github.com/quiltdata/quilt/pull/5217))
- [Fixed] Search sidebar: the "Sort by" control no longer disappears while typing in "Find metadata" on stacks with truncated facet lists, it is withheld when the query matches nothing rather than offering to sort an empty list, and it has an accessible name — its label used to land on a hidden input, reading as an unnamed button to assistive tech ([#5217](https://github.com/quiltdata/quilt/pull/5217))
- [Fixed] Queries: the query selector announces its label to assistive tech, and no longer claims "Custom" is loaded while its helper text reports the query failed to load ([#5217](https://github.com/quiltdata/quilt/pull/5217))
- [Fixed] Admin Users: a disabled Enabled or Admin switch explains why on hover or keyboard focus — "you can't deactivate yourself" and "managed by the stack" used to render as the same mute, dead control — and the roles dialog and Role column read as read-only end to end for service users rather than offering a Save the registry refuses ([#5217](https://github.com/quiltdata/quilt/pull/5217))
- [Changed] With the `data-products` preview off, the demo fixture data no longer ships in the bundles a browser downloads on the volumes landing; the adapter behind the preview loads only when the feature is on ([#5217](https://github.com/quiltdata/quilt/pull/5217))
Comment on lines +24 to +28

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f27 · substantive · scope

Five subsystems sharing no code and no call path: A queryRedirects.jsx+spec plus Athena/{Athena,History,model/state,model/requests}; B Search/{model,Layout/PackageFilters}; C Queries/QuerySelect+snapshot; D Admin/UsersAndRoles/Users; E model/DataProducts/{hooks,index}. The DataProducts extraction is the one worth making first: the only [Changed] in the set, the only app/model layer change, and the only fix whose stated verification (a production build's chunk contents) leaves no artifact in the repo a reviewer or a later bisect can re-check. FAILURE SCENARIO: one regression post-release makes git revert of #5217 take the other four fixes with it, and all five CHANGELOG entries cite #5217, so any partial revert requires hand-editing catalog/CHANGELOG.md and cherry-picking hunks out of a 22-file commit under release pressure.

Subject: six independent fixes gathered as one unit — split candidate · anchored at 491639d · a reply here is read and dispositioned at the next pass.

- [Removed] The unbaked data-products GraphQL contract (added in [#5203](https://github.com/quiltdata/quilt/pull/5203), never served by a registry) is out of the schema; the `data-products` preview UI is unaffected and keeps reading fixture data ([#5223](https://github.com/quiltdata/quilt/pull/5223))
- [Changed] Search sidebar: the package metadata list is sorted from one "Sort by" control, sitting directly above the list ([#5222](https://github.com/quiltdata/quilt/pull/5222))
- [Fixed] Volumes list: the "Shared with" readout no longer overlaps a bucket's tags, and the extra space inside it is gone ([#5220](https://github.com/quiltdata/quilt/pull/5220))
Expand Down
216 changes: 216 additions & 0 deletions catalog/app/containers/Admin/UsersAndRoles/Users.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import * as React from 'react'
import { render, cleanup, fireEvent, screen } from '@testing-library/react'
import { describe, it, expect, vi, afterEach } from 'vitest'

import { EditableSwitch, columns } from './Users'

vi.mock('constants/config', () => ({ default: {} }))

describe('containers/Admin/UsersAndRoles/Users', () => {
describe('EditableSwitch', () => {
afterEach(cleanup)

it('explains why it is disabled', async () => {
// A dead control with no cause is indistinguishable from a rendering
// bug -- and two causes (self, service user) share this column.
const { container } = render(
<EditableSwitch
hint="Deactivated users can't sign in"
disabled
disabledReason="This service user is managed by the stack"
checked
onChange={vi.fn()}
/>,
)
expect(container.querySelector('input')?.disabled).toBe(true)
fireEvent.mouseOver(container.querySelector('span')!)
expect(
await screen.findByText('This service user is managed by the stack'),
).toBeDefined()
})

it('carries the reason to keyboard and screen-reader users', async () => {
// The switch is disabled, so it is out of the tab order and fires no
// events: without a focusable, named wrapper the reason is mouse-only.
render(
<EditableSwitch
hint="Deactivated users can't sign in"
disabled
disabledReason="This service user is managed by the stack"
checked
onChange={vi.fn()}
/>,
)
const wrapper = screen.getByLabelText('This service user is managed by the stack')
expect(wrapper.getAttribute('tabindex')).toBe('0')

fireEvent.focus(wrapper)
expect(
await screen.findByText('This service user is managed by the stack'),
).toBeDefined()
})

it('keeps the hint on the enabled control', async () => {
const { container } = render(
<EditableSwitch
hint="Deactivated users can't sign in"
checked
onChange={vi.fn()}
/>,
)
// MUI seeds the tooltip as a `title` before opening the popper; hover the
// node that actually carries it.
const titled = container.querySelector('[title]')!
expect(titled.getAttribute('title')).toBe("Deactivated users can't sign in")
fireEvent.mouseOver(titled)
expect(await screen.findByText("Deactivated users can't sign in")).toBeDefined()
})

it('renders a plain disabled switch when no reason is given', () => {
const { container } = render(
<EditableSwitch
hint="Deactivated users can't sign in"
disabled
checked
onChange={vi.fn()}
/>,
)
expect(container.querySelector('input')?.disabled).toBe(true)
})
})

describe('the Admin column switch', () => {
afterEach(cleanup)

const column = columns.find((c) => c.id === 'isAdmin')!

function renderSwitch(user: object, isSelf = false) {
return render(
<>
{column.getDisplay!(undefined, user as never, { isSelf, openDialog: vi.fn() })}
</>,
)
}

it.each([
[
'a service user',
{ isAdminAssignmentDisabled: true, isService: true },
false,
'This service user is managed by the stack',
],
[
'an SSO-managed user',
{ isAdminAssignmentDisabled: true, isService: false },
false,
'Admin capabilities for this user are managed by the SSO configuration',
],
[
'yourself',
{ isAdminAssignmentDisabled: false, isService: false },
true,
'You cannot change your own admin status',
],
])('says why it is disabled for %s', async (_label, user, isSelf, reason) => {
const { container } = renderSwitch({ name: 'u', isAdmin: false, ...user }, isSelf)
expect(container.querySelector('input')?.disabled).toBe(true)
fireEvent.mouseOver(container.querySelector('span')!)
expect(await screen.findByText(reason)).toBeDefined()
})

it('stays editable for an ordinary user', () => {
const { container } = renderSwitch({
name: 'u',
isAdmin: false,
isAdminAssignmentDisabled: false,
isService: false,
})
expect(container.querySelector('input')?.disabled).toBe(false)
})
})

describe('the Enabled column switch', () => {
afterEach(cleanup)

const column = columns.find((c) => c.id === 'isActive')!

function renderSwitch(user: object, isSelf = false) {
return render(
<>
{column.getDisplay!(
undefined,
user as never,
{
isSelf,
setActive: vi.fn(),
} as never,
)}
</>,
)
}

it.each([
[
'a service user',
{ isService: true },
false,
'This service user is managed by the stack',
],
['yourself', { isService: false }, true, 'You cannot deactivate your own account'],
])('says why it is disabled for %s', async (_label, user, isSelf, reason) => {
const { container } = renderSwitch({ name: 'u', isActive: true, ...user }, isSelf)
expect(container.querySelector('input')?.disabled).toBe(true)
fireEvent.mouseOver(container.querySelector('span')!)
expect(await screen.findByText(reason)).toBeDefined()
})

it('stays editable for an ordinary user', () => {
const { container } = renderSwitch({ name: 'u', isActive: true, isService: false })
expect(container.querySelector('input')?.disabled).toBe(false)
})
})

describe('the Role column', () => {
afterEach(cleanup)

const column = columns.find((c) => c.id === 'role')!

function renderRole(user: object) {
return render(
<>
{column.getDisplay!(
undefined,
{ extraRoles: [], ...user } as never,
{
roles: [],
defaultRole: null,
openDialog: vi.fn(),
} as never,
)}
</>,
)
}

// The dialog this opens is read-only for both flags, so the invitation to
// edit must be gated on the same pair.
it.each([
['an SSO-managed user', { isRoleAssignmentDisabled: true, isService: false }],
['a service user', { isRoleAssignmentDisabled: false, isService: true }],
])('offers only viewing for %s', (_label, user) => {
const { container } = renderRole(user)
expect(container.querySelector('[title]')?.getAttribute('title')).toBe(
'Click to view',
)
})

it('offers editing for an ordinary user', () => {
const { container } = renderRole({
isRoleAssignmentDisabled: false,
isService: false,
})
expect(container.querySelector('[title]')?.getAttribute('title')).toBe(
'Click to edit',
)
})
})
})
Loading