Fleet UI: Cap policy/report name input at 255 + truncate long names with tooltip on details pages (#48154)

This commit is contained in:
RachelElysia
2026-06-24 10:20:18 -04:00
committed by GitHub
parent 7bac963f31
commit 0c4e7e6f0b
15 changed files with 170 additions and 7 deletions
+3
View File
@@ -103,6 +103,9 @@ Render software title names via `getDisplayedSoftwareName(name, display_name)` f
- Style files use underscore prefix: `_styles.scss`
- Prefer `gap` over `margin` for spacing between sibling elements when the parent is `display: flex`/`grid`. Use the layout mixins from `frontend/styles/var/mixins.scss`: `vertical-card-layout`, `vertical-form-layout`, `vertical-modal-layout`, `vertical-page-layout`, `vertical-page-tab-panel-layout`, `vertical-data-set-layout`
## Forms
Cap free-text inputs' `maxLength` to the backend column length (check `server/datastore/mysql/schema.sql`, don't guess) via `inputOptions={{ maxLength: NAME_MAX_LENGTH }}` on `InputField`, using a local constant.
## Lists & rows
User-typed free-text fields (`name`, `title`, `label`, `description`) inside an `UploadList` `ListItemComponent`, a `__row` flex container with sibling actions/badges, or a `TableContainer` open-text cell — wrap the value in `<TooltipTruncatedText value={...} />` and give the immediate parent `flex: 1; min-width: 0`.
@@ -13,6 +13,7 @@ import FileSaver from "file-saver";
import Spinner from "components/Spinner";
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import {
getPerformanceImpactDescription,
getPerformanceImpactIndicatorTooltip,
@@ -173,8 +174,10 @@ const HQRTable = ({
const renderTableInfo = useCallback(
() => (
<div className={`${baseClass}__query-info`}>
<div>
<h2>{queryName}</h2>
<div className={`${baseClass}__query-info-text`}>
<h2>
<TooltipTruncatedText value={queryName} fixedPositionStrategy />
</h2>
<h3>{queryDescription}</h3>
</div>
<PerformanceImpact queryStats={queryStats} queryId={queryId} />
@@ -17,14 +17,15 @@
&__query-info {
display: flex;
align-items: start;
align-items: baseline;
justify-content: space-between;
gap: $pad-xsmall;
gap: $pad-medium;
h2 {
font-size: $small;
font-weight: $bold;
margin: 0;
overflow: hidden;
}
h3 {
font-size: $x-small;
@@ -38,6 +39,11 @@
}
}
&__query-info-text {
flex: 1;
min-width: 0;
}
.data-table {
overflow-x: auto;
}
@@ -31,6 +31,7 @@ import MainContent from "components/MainContent";
import PageDescription from "components/PageDescription";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import Avatar from "components/Avatar";
import ShowQueryModal from "components/modals/ShowQueryModal";
import { getTicketOrWebhookInfo } from "pages/policies/helpers";
@@ -367,7 +368,10 @@ const PolicyDetailsPage = ({
<div className={`${baseClass}__title-bar`}>
<div className={`${baseClass}__name-description`}>
<h1 className={`${baseClass}__policy-name`}>
{storedPolicy?.name}
<TooltipTruncatedText
value={storedPolicy?.name}
fixedPositionStrategy
/>
{storedPolicy?.critical && (
<TooltipWrapper
tipContent="This policy has been marked as critical."
@@ -18,6 +18,11 @@
gap: $pad-small;
}
&__name-description {
flex: 1;
min-width: 0;
}
&__platform-list {
flex-direction: row;
}
@@ -34,6 +39,11 @@
display: flex;
align-items: center;
gap: $pad-small;
overflow: hidden;
.tooltip-truncated-text {
min-width: 0;
}
.critical-policy-icon {
flex-shrink: 0;
@@ -85,6 +85,23 @@ describe("PolicyForm - component", () => {
expect(screen.queryByText("All hosts")).not.toBeInTheDocument();
});
it("caps the policy name input at 255 characters in edit mode", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(<PolicyForm {...defaultProps} />);
expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255");
});
describe("in premium tier", () => {
beforeEach(() => {
mockServer.use(labelSummariesHandler);
@@ -64,6 +64,8 @@ import SaveNewPolicyModal from "../SaveNewPolicyModal";
const baseClass = "policy-form";
const NAME_MAX_LENGTH = 255;
interface IPolicyFormProps {
router: InjectedRouter;
teamIdForApi?: number;
@@ -518,6 +520,7 @@ const PolicyForm = ({
error={errors && errors.name}
onChange={(value: string) => setLastEditedQueryName(value)}
disabled={gitOpsModeEnabled}
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
);
}
@@ -87,6 +87,23 @@ describe("SaveNewPolicyModal", () => {
expect(screen.queryByText("All hosts")).not.toBeInTheDocument();
});
it("caps the policy name input at 255 characters", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(<SaveNewPolicyModal {...defaultProps} />);
expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255");
});
describe("in premium tier", () => {
const render = createCustomRenderer({
withBackendMock: true,
@@ -39,6 +39,8 @@ import PolicyAutomationsFields, {
import { usePolicyLabelTargets } from "pages/policies/hooks";
import { POLICY_TARGET_EMPTY_STATE_DESCRIPTION } from "pages/policies/constants";
const NAME_MAX_LENGTH = 255;
export interface ISaveNewPolicyModalProps {
baseClass: string;
queryValue: string;
@@ -342,6 +344,7 @@ const SaveNewPolicyModal = ({
label="Name"
autofocus
disabled={disableForm}
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
<InputField
name="description"
@@ -28,6 +28,7 @@ import Button from "components/buttons/Button";
import BackButton from "components/BackButton";
import MainContent from "components/MainContent";
import TooltipWrapper from "components/TooltipWrapper/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import QueryAutomationsStatusIndicator from "pages/queries/ManageQueriesPage/components/QueryAutomationsStatusIndicator/QueryAutomationsStatusIndicator";
import DataError from "components/DataError/DataError";
import LogDestinationIndicator from "components/LogDestinationIndicator/LogDestinationIndicator";
@@ -246,9 +247,12 @@ const QueryDetailsPage = ({
{!isLoading && !isApiError && (
<>
<div className={`${baseClass}__title-bar`}>
<div className="name-description">
<div className={`${baseClass}__name-description`}>
<h1 className={`${baseClass}__query-name`}>
{storedQuery?.name}
<TooltipTruncatedText
value={storedQuery?.name}
fixedPositionStrategy
/>
</h1>
</div>
<div className={`${baseClass}__action-button-container`}>
@@ -11,6 +11,11 @@
gap: $pad-medium;
}
&__name-description {
flex: 1;
min-width: 0;
}
&__action-button-container {
display: flex;
justify-content: flex-end;
@@ -21,6 +26,7 @@
&__query-name {
margin-top: 0;
font-size: $large;
overflow: hidden;
}
&__settings {
@@ -111,6 +111,69 @@ describe("EditQueryForm - component", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
it("caps the report name input at 255 characters in edit mode", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
query: {
lastEditedQueryId: mockQuery.id,
lastEditedQueryName: mockQuery.name,
lastEditedQueryDescription: mockQuery.description,
lastEditedQueryBody: mockQuery.query,
lastEditedQueryObserverCanRun: mockQuery.observer_can_run,
lastEditedQueryFrequency: mockQuery.interval,
lastEditedQueryAutomationsEnabled: mockQuery.automations_enabled,
lastEditedQueryPlatforms: mockQuery.platform,
lastEditedQueryMinOsqueryVersion: mockQuery.min_osquery_version,
lastEditedQueryLoggingType: mockQuery.logging,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryObserverCanRun: jest.fn(),
setLastEditedQueryFrequency: jest.fn(),
setLastEditedQueryAutomationsEnabled: jest.fn(),
setLastEditedQueryPlatforms: jest.fn(),
setLastEditedQueryMinOsqueryVersion: jest.fn(),
setLastEditedQueryLoggingType: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: false,
isSandboxMode: false,
config: createMockConfig(),
},
},
});
render(
<EditQueryForm
router={mockRouter}
location={mockLocation}
queryIdForEdit={1}
apiTeamIdForQuery={1}
showOpenSchemaActionText
storedQuery={createMockQuery()}
isStoredQueryLoading={false}
isQuerySaving={false}
isQueryUpdating={false}
onSubmitNewQuery={jest.fn()}
onOsqueryTableSelect={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
showConfirmSaveChangesModal={false}
setShowConfirmSaveChangesModal={jest.fn()}
/>
);
expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255");
});
it("disables live query button for globally disabled live queries", async () => {
const render = createCustomRenderer({
withBackendMock: true,
@@ -76,6 +76,8 @@ import SaveAsNewQueryModal from "../SaveAsNewQueryModal";
const baseClass = "edit-query-form";
const NAME_MAX_LENGTH = 255;
interface IEditQueryFormProps {
router: InjectedRouter;
location: Location;
@@ -464,6 +466,7 @@ const EditQueryForm = ({
setLastEditedQueryName(lastEditedQueryName.trim());
}}
disabled={gitOpsModeEnabled}
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
);
}
@@ -106,6 +106,23 @@ describe("SaveNewQueryModal", () => {
await user.click(advancedOptionsButton);
});
it("caps the report name input at 255 characters", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(<SaveNewQueryModal {...defaultProps} />);
expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255");
});
it("displays error when query name is empty", async () => {
const render = createCustomRenderer({
withBackendMock: true,
@@ -47,6 +47,9 @@ import labelsAPI, {
import DiscardDataOption from "../DiscardDataOption";
const baseClass = "save-query-modal";
const NAME_MAX_LENGTH = 255;
export interface ISaveNewQueryModalProps {
queryValue: string;
apiTeamIdForQuery?: number; // query will be global if omitted
@@ -238,6 +241,7 @@ const SaveNewQueryModal = ({
inputClassName={`${baseClass}__name`}
label="Name"
autofocus
inputOptions={{ maxLength: NAME_MAX_LENGTH }}
/>
<InputField
name="description"