diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 34167a9dd1..42fa9e662d 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.32.0", + "version": "7.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.32.0", + "version": "7.33.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 5e746427ed..4e6cc97857 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.32.0", + "version": "7.33.0", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 5d030258b4..49f59a3138 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,13 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.33.0 +*Released*: 24 April 2026 +- Workflow UI updates + - Add css for .lk-popover-behind-modal to adjust z-index + - EditInlineField prop for pullRight for pencil icon + - UserDetailsPanel support for userId that is a group id + ### version 7.32.0 *Released*: 23 April 2026 - Add EMPTY_COMPOUND_WARNING diff --git a/packages/components/src/internal/components/EditInlineField.test.tsx b/packages/components/src/internal/components/EditInlineField.test.tsx index 4210cceb6c..180f4f50db 100644 --- a/packages/components/src/internal/components/EditInlineField.test.tsx +++ b/packages/components/src/internal/components/EditInlineField.test.tsx @@ -34,7 +34,7 @@ describe('EditInlineField', () => { }, }; - function validate(editing = false, allowEdit = true, type?: Record): void { + function validate(editing = false, allowEdit = true, type?: Record, pullRight?: boolean): void { expect(document.querySelectorAll('.edit-inline-field__label')).toHaveLength(!editing ? 1 : 0); expect(document.querySelectorAll('.edit-inline-field__toggle')).toHaveLength(!editing && allowEdit ? 1 : 0); expect(document.querySelectorAll('.fa-pencil')).toHaveLength(!editing && allowEdit ? 1 : 0); @@ -47,6 +47,8 @@ describe('EditInlineField', () => { expect(document.querySelectorAll('input')).toHaveLength(type?.text ?? type?.date ?? 0); expect(document.querySelectorAll('.user-link')).toHaveLength(type?.user ?? 0); + + expect(document.querySelectorAll('.pull-right')).toHaveLength(pullRight ? 1 : 0); } test('default props', async () => { @@ -163,8 +165,6 @@ describe('EditInlineField', () => { renderWithAppContext( { rangeURI: DATETIME_RANGE_URI, }) } + type="date" + value="2022-08-11 18:00:00" />, { serverContext: SERVER_CONTEXT, appContext: APP_CONTEXT } ); @@ -186,8 +188,6 @@ describe('EditInlineField', () => { renderWithAppContext( { rangeURI: DATETIME_RANGE_URI, }) } + type="date" + value="2022-08-11 18:00:00.123" />, { serverContext: SERVER_CONTEXT, appContext: APP_CONTEXT } ); @@ -211,7 +213,6 @@ describe('EditInlineField', () => { renderWithAppContext( { rangeURI: TIME_RANGE_URI, }) } + value="18:00:00.1234" />, { serverContext: SERVER_CONTEXT, appContext: APP_CONTEXT } ); @@ -280,4 +282,16 @@ describe('EditInlineField', () => { expect(document.querySelectorAll('input[name="t$De$Sst"]')).toHaveLength(1); expect(document.querySelectorAll('input[name="t.e/st"]')).toHaveLength(0); }); + + test('pullRight', async () => { + renderWithAppContext(, { + serverContext: SERVER_CONTEXT, + appContext: APP_CONTEXT, + }); + validate(false, true, undefined, true); + expect(document.querySelector('.edit-inline-field__placeholder')).toHaveTextContent(''); + expect(document.querySelectorAll('.fa-pencil')).toHaveLength(1); + await userEvent.click(document.querySelector('.edit-inline-field__toggle')); + validate(true, true, { text: 1 }, false); + }); }); diff --git a/packages/components/src/internal/components/EditInlineField.tsx b/packages/components/src/internal/components/EditInlineField.tsx index 38fcc55044..3cfcd52fd3 100644 --- a/packages/components/src/internal/components/EditInlineField.tsx +++ b/packages/components/src/internal/components/EditInlineField.tsx @@ -33,6 +33,7 @@ interface Props { name: string; onChange?: (name: string, newValue: any) => void; placeholder?: string; + pullRight?: boolean; startDate?: Date; tooltip?: string; // only shown when component has a label and is allowEdit type: string; @@ -52,6 +53,7 @@ export const EditInlineField: FC = memo(props => { name, onChange, placeholder, + pullRight, startDate, tooltip, type, @@ -313,8 +315,9 @@ export const EditInlineField: FC = memo(props => { onKeyDown={toggleKeyDown} tabIndex={1} > + {allowEdit && pullRight && } {!isUser && displayValue} - {allowEdit && } + {allowEdit && !pullRight && } )} diff --git a/packages/components/src/internal/components/permissions/actions.ts b/packages/components/src/internal/components/permissions/actions.ts index cd51e093cb..3542980b0c 100644 --- a/packages/components/src/internal/components/permissions/actions.ts +++ b/packages/components/src/internal/components/permissions/actions.ts @@ -15,6 +15,8 @@ import { APPLICATION_ROLES_DESCRIPTIONS, APPLICATION_ROLES_LABELS } from '../adm import { Principal, SecurityPolicy, SecurityRole } from './models'; import { executeSql } from '../../query/executeSql'; +import { selectRows } from '../../query/selectRows'; +import { SCHEMAS } from '../../schemas'; export function processGetRolesResponse(rawRoles: any): List { let roles = List(); @@ -47,6 +49,20 @@ export async function getPrincipals(): Promise> { return result.rows.reduce>((p, row) => p.push(Principal.createFromSelectRow(fromJS(row))), List()); } +export async function getPrincipalById(principalId: number): Promise { + const results = await selectRows({ + columns: ['UserId', 'Name', 'Type'], + schemaQuery: SCHEMAS.CORE_TABLES.PRINCIPALS, + filterArray: [Filter.create('UserId', principalId)], + }); + + if (results.rows.length === 1) { + const row = results.rows[0]; + return Principal.createFromSelectRow(fromJS(row)); + } + return undefined; +} + export function getInactiveUsers(): Promise> { return new Promise((resolve, reject) => { selectRowsDeprecated({ diff --git a/packages/components/src/internal/components/permissions/models.test.ts b/packages/components/src/internal/components/permissions/models.test.ts index b3afe9aece..c0c488c938 100644 --- a/packages/components/src/internal/components/permissions/models.test.ts +++ b/packages/components/src/internal/components/permissions/models.test.ts @@ -103,6 +103,18 @@ describe('Principal model', () => { expect(sortedPrincipals.get(1)).toBe(USER1); expect(sortedPrincipals.get(2)).toBe(GROUP); }); + + test('isGroup', () => { + expect(GROUP.isGroup()).toBeTruthy(); + expect(USER1.isGroup()).toBeFalsy(); + expect(USER2.isGroup()).toBeFalsy(); + }); + + test('isUser', () => { + expect(GROUP.isUser()).toBeFalsy(); + expect(USER1.isUser()).toBeTruthy(); + expect(USER2.isUser()).toBeTruthy(); + }); }); describe('SecurityRole model', () => { diff --git a/packages/components/src/internal/components/permissions/models.tsx b/packages/components/src/internal/components/permissions/models.tsx index 61c551087f..c4efa9f1e1 100644 --- a/packages/components/src/internal/components/permissions/models.tsx +++ b/packages/components/src/internal/components/permissions/models.tsx @@ -64,6 +64,14 @@ export class Principal extends Record({ .toList() ); } + + isGroup(): boolean { + return this.type === 'g'; + } + + isUser(): boolean { + return this.type === 'u'; + } } export class SecurityRole extends Record({ diff --git a/packages/components/src/internal/components/security/APIWrapper.ts b/packages/components/src/internal/components/security/APIWrapper.ts index 0ae8c5651c..7f5e36244a 100644 --- a/packages/components/src/internal/components/security/APIWrapper.ts +++ b/packages/components/src/internal/components/security/APIWrapper.ts @@ -5,6 +5,7 @@ import { Container } from '../base/models/Container'; import { fetchContainers, fetchContainerSecurityPolicy, + getPrincipalById, getUserLimitSettings, processGetRolesResponse, UserLimitSettings, @@ -76,6 +77,7 @@ export interface SecurityAPIWrapper { getDeletionSummaries: (containerPath?: string) => Promise; getGroupMemberships: () => Promise; getInheritedContainers: (container: Container) => Promise; + getPrincipalById: (principalId: number) => Promise; getUserLimitSettings: (containerPath?: string) => Promise; getUserPermissions: (options: GetUserPermissionsOptions) => Promise; getUserProperties: (userId: number) => Promise; @@ -284,6 +286,8 @@ export class ServerSecurityAPIWrapper implements SecurityAPIWrapper { }, []); }; + getPrincipalById = getPrincipalById; + getUserLimitSettings = getUserLimitSettings; getUserPermissions = (options: GetUserPermissionsOptions): Promise => { @@ -437,6 +441,7 @@ export function getSecurityTestAPIWrapper( getAuditLogDate: mockFn(), getDeletionSummaries: mockFn(), getGroupMemberships: mockFn(), + getPrincipalById: mockFn(), getUserLimitSettings: mockFn(), getUserPermissions: mockFn(), getUserProperties: mockFn(), diff --git a/packages/components/src/internal/components/user/UserDetailsPanel.test.tsx b/packages/components/src/internal/components/user/UserDetailsPanel.test.tsx index 1df2114cff..8b835baa46 100644 --- a/packages/components/src/internal/components/user/UserDetailsPanel.test.tsx +++ b/packages/components/src/internal/components/user/UserDetailsPanel.test.tsx @@ -6,7 +6,7 @@ import rolesJSON from '../../../test/data/security-getRoles.json'; import userPropsInfo from '../../../test/data/user-getUserProps.json'; import { JEST_SITE_ADMIN_USER_ID } from '../../../test/data/constants'; -import { SecurityPolicy } from '../permissions/models'; +import { Principal, SecurityPolicy } from '../permissions/models'; import { TEST_USER_APP_ADMIN } from '../../userFixtures'; @@ -43,18 +43,18 @@ const SERVER_CONTEXT = { user: TEST_USER_APP_ADMIN, moduleContext: { samplemanagement: {}, - } + }, }; describe('', () => { test('no principal', async () => { const component = ( ); let container; @@ -70,13 +70,13 @@ describe('', () => { test('with principal no buttons because of self', async () => { const component = ( ); let container; @@ -92,12 +92,12 @@ describe('', () => { test('with principal and buttons', async () => { const component = ( ); let container; @@ -113,14 +113,14 @@ describe('', () => { test('with principal and buttons not allowDelete or allowResetPassword', async () => { const component = ( ); let container; @@ -136,16 +136,45 @@ describe('', () => { test('unknown user props', async () => { const component = ( + ); + let container; + await act(async () => { + container = renderWithAppContext(component, { + appContext: APP_CONTEXT, + serverContext: SERVER_CONTEXT, + }).container; + }); + expect(container).toMatchSnapshot(); + }); + + test('with principal that isGroup', async () => { + const GROUP_ID = 1006; + const groupPrincipal = Principal.create({ userId: GROUP_ID, type: 'g', displayName: 'Test Group' }); + const component = ( + ); let container; @@ -157,4 +186,84 @@ describe('', () => { }); expect(container).toMatchSnapshot(); }); + + test('with principal that isGroup in modal uses Close button and hides Manage footer', async () => { + const GROUP_ID = 1006; + const groupPrincipal = Principal.create({ userId: GROUP_ID, type: 'g', displayName: 'Test Group' }); + await act(async () => { + renderWithAppContext( + , + { appContext: APP_CONTEXT, serverContext: SERVER_CONTEXT } + ); + }); + const modalFooter = document.body.querySelector('.modal-footer'); + expect(modalFooter.querySelector('button')).toHaveTextContent('Close'); + expect(modalFooter.textContent).not.toContain('Cancel'); + expect(modalFooter.textContent).not.toContain('Manage'); + expect(document.body.querySelector('.modal-body').textContent).toContain('ID'); + expect(document.body.querySelector('.modal-body').textContent).not.toContain('Email'); + }); + + test('with user in modal shows Cancel button and user fields', async () => { + await act(async () => { + renderWithAppContext( + , + { appContext: APP_CONTEXT, serverContext: SERVER_CONTEXT } + ); + }); + const modalFooter = document.body.querySelector('.modal-footer'); + expect(modalFooter.querySelector('button')).toHaveTextContent('Cancel'); + expect(modalFooter.textContent).not.toContain('Close'); + expect(document.body.querySelector('.modal-body').textContent).toContain('Email'); + expect(document.body.querySelector('.modal-body').textContent).toContain('ID'); + }); + + test('with principal that isGroup and onUsersStateChangeComplete suppresses action buttons', async () => { + const GROUP_ID = 1006; + const groupPrincipal = Principal.create({ userId: GROUP_ID, type: 'g', displayName: 'Test Group' }); + let container; + await act(async () => { + container = renderWithAppContext( + , + { appContext: APP_CONTEXT, serverContext: SERVER_CONTEXT } + ).container; + }); + // Action buttons (Delete, Reactivate) must not appear for groups + expect(container.querySelectorAll('button')).toHaveLength(0); + }); }); diff --git a/packages/components/src/internal/components/user/UserDetailsPanel.tsx b/packages/components/src/internal/components/user/UserDetailsPanel.tsx index 41fe805204..6263a0c3a5 100644 --- a/packages/components/src/internal/components/user/UserDetailsPanel.tsx +++ b/packages/components/src/internal/components/user/UserDetailsPanel.tsx @@ -12,7 +12,7 @@ import { Modal } from '../../Modal'; import { caseInsensitive } from '../../util/utils'; import { LoadingSpinner } from '../base/LoadingSpinner'; import { formatDate, getDateFNSDateTimeFormat, parseDate } from '../../util/Date'; -import { SecurityPolicy, SecurityRole } from '../permissions/models'; +import { Principal, SecurityPolicy, SecurityRole } from '../permissions/models'; import { EffectiveRolesList } from '../permissions/EffectiveRolesList'; import { GroupsList } from '../permissions/GroupsList'; @@ -106,6 +106,7 @@ export const UserDetailsPanel: FC = props => { const [loading, setLoading] = useState(false); const [policyState, setPolicyState] = useState(undefined); + const [principal, setPrincipal] = useState(undefined); const [rolesByUniqueNameState, setRolesByUniqueNameState] = useState | undefined>( undefined ); @@ -129,6 +130,7 @@ export const UserDetailsPanel: FC = props => { const loadUserDetails = useCallback(async () => { if (!userId) { setUserProperties(undefined); + setPrincipal(undefined); setShowResetTotp(false); return; } @@ -136,6 +138,9 @@ export const UserDetailsPanel: FC = props => { setLoading(true); try { + const principal = await api.getPrincipalById(userId); + setPrincipal(principal); + if (isSelf) { const response = await api.getUserProperties(userId); setUserProperties(response.props); @@ -252,6 +257,7 @@ export const UserDetailsPanel: FC = props => { } if (userProperties) { + const isGroup = principal?.isGroup() ?? false; const description = caseInsensitive(userProperties, 'description'); let name = caseInsensitive(userProperties, 'firstName') ?? ''; if (name) { @@ -262,17 +268,28 @@ export const UserDetailsPanel: FC = props => { return ( <> - {!!name && } - - {description && } - -
- - - -
- - {!!hasPassword && } + {!isGroup && ( + <> + {!!name && } + + {description && ( + + )} + +
+ + + +
+ + {!!hasPassword && } + + )} + {isGroup && ( + <> + + + )} = props => { const { user, project } = getServerContext(); const isSelfCtx = userId === user.id; + const isGroup = principal?.isGroup() ?? false; if (toggleDetailsModal) { let footer: ReactNode; - if (user.isAdmin) { + if (user.isAdmin && !isGroup) { // We do not currently support user management in sub folders, so we create the management URL for the project // container. const manageUrl = AppURL.create(ADMIN_KEY, 'users') @@ -333,7 +351,13 @@ export const UserDetailsPanel: FC = props => { } return ( - + {renderBody()} ); @@ -344,7 +368,7 @@ export const UserDetailsPanel: FC = props => {
{renderHeader()}
{renderBody()} - {!isSelfCtx && onUsersStateChangeComplete && renderButtons()} + {!isSelfCtx && !isGroup && onUsersStateChangeComplete && renderButtons()} {allowResetPassword && showDialog === 'reset' && ( with principal no buttons because of self 1`] = `
`; + +exports[` with principal that isGroup 1`] = ` +
+
+
+ + Test Group + +
+
+
+
+ ID +
+
+ 1006 +
+
+
+
+
+`; diff --git a/packages/components/src/internal/schemas.ts b/packages/components/src/internal/schemas.ts index 62fa907eef..935f2a303b 100644 --- a/packages/components/src/internal/schemas.ts +++ b/packages/components/src/internal/schemas.ts @@ -55,6 +55,7 @@ const CORE_SCHEMA = 'core'; export const CORE_TABLES = { SCHEMA: CORE_SCHEMA, DATA_STATES: new SchemaQuery(CORE_SCHEMA, 'DataStates'), + PRINCIPALS: new SchemaQuery(CORE_SCHEMA, 'Principals'), SITE_USERS: new SchemaQuery(CORE_SCHEMA, 'SiteUsers'), USERS: new SchemaQuery(CORE_SCHEMA, 'Users'), USER_API_KEYS: new SchemaQuery(CORE_SCHEMA, 'UserApiKeys'), diff --git a/packages/components/src/theme/detail.scss b/packages/components/src/theme/detail.scss index 82b6975ff7..b50aeba7a7 100644 --- a/packages/components/src/theme/detail.scss +++ b/packages/components/src/theme/detail.scss @@ -196,7 +196,8 @@ } .detail__header--name { - margin: 0; + margin: 0; + overflow-wrap: break-word; } .detail__header--data-class { diff --git a/packages/components/src/theme/overlay.scss b/packages/components/src/theme/overlay.scss index 596433f551..b99892c7fc 100644 --- a/packages/components/src/theme/overlay.scss +++ b/packages/components/src/theme/overlay.scss @@ -3,6 +3,10 @@ z-index: 1100; } +.popover.lk-popover-behind-modal { + z-index: 1040; +} + .tooltip.lk-tooltip { margin: 0; }