feat: Adds a status indicator setting in List Item and Tab (#39938)

## Description

Adds ability across UI elements to show warning indicators

## Automation

/ok-to-test tags="@tag.Sanity"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/14083540998>
> Commit: 022e693f2474a12a70ed3ae317ed26d9eb7caed6
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=14083540998&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Sanity`
> Spec:
> <hr>Wed, 26 Mar 2025 13:24:06 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added visual indicators for unsaved changes across list and tab
components—badges now appear to alert users of pending modifications.
- Enhanced the code editor by introducing dynamic line highlighting,
allowing users to easily identify and navigate to specific code
sections.
- Introduced new selectors to improve management of JavaScript
collection actions and their schema states.

- **Style**
- Upgraded badge styling with a new "info" variant and adjustable size
options, resulting in a more refined and consistent user experience.
- Added a new styled component for unsaved changes, enhancing visual
feedback for users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Hetu Nandu 2025-03-26 19:28:52 +05:30 committed by GitHub
parent e3a49845e7
commit 074fe6fd37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 175 additions and 12 deletions

View File

@ -1,5 +1,5 @@
import styled, { css } from "styled-components";
import type { BadgeKind } from "./Badge.types";
import type { BadgeKind, BadgeSize } from "./Badge.types";
const Kind = {
error: css`
@ -11,14 +11,28 @@ const Kind = {
success: css`
--badge-color-bg: var(--ads-v2-color-fg-success);
`,
info: css`
--badge-color-bg: var(--ads-v2-color-fg);
`,
};
const Size = {
small: css`
width: 5px;
height: 5px;
`,
medium: css`
width: 8px;
height: 8px;
`,
};
export const StyledBadge = styled.div<{
kind?: BadgeKind;
size?: BadgeSize;
}>`
width: 8px;
height: 8px;
background-color: var(--badge-color-bg);
border-radius: 50%;
${({ kind }) => kind && Kind[kind]}
${({ size }) => size && Size[size]}
`;

View File

@ -10,6 +10,13 @@ import { StyledBadge } from "./Badge.styles";
* @param className
* @constructor
*/
export function Badge({ className, kind = "success", ...rest }: BadgeProps) {
return <StyledBadge className={className} kind={kind} {...rest} />;
export function Badge({
className,
kind = "success",
size = "medium",
...rest
}: BadgeProps) {
return (
<StyledBadge className={className} kind={kind} size={size} {...rest} />
);
}

View File

@ -1,10 +1,13 @@
import type { Kind } from "../__config__/types";
export type BadgeKind = Exclude<Kind, "info" | undefined>;
export type BadgeKind = Exclude<Kind, undefined>;
export type BadgeSize = "small" | "medium";
export interface BadgeProps {
/** visual style to be used indicating type of badge */
kind?: BadgeKind;
/** (try not to) pass addition classes here */
className?: string;
/** Size of the badge */
size?: BadgeSize;
}

View File

@ -44,6 +44,14 @@ export const RightControlWrapper = styled.div`
align-items: center;
`;
export const UnsavedChangesWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
`;
export const TopContentWrapper = styled.div`
display: flex;
align-items: center;

View File

@ -13,6 +13,7 @@ import {
StyledListItem,
TooltipTextWrapper,
TopContentWrapper,
UnsavedChangesWrapper,
} from "./List.styles";
import type { TextProps } from "../Text";
import { Text } from "../Text";
@ -26,6 +27,7 @@ import {
ListItemTitleClassName,
} from "./List.constants";
import { useEventCallback } from "usehooks-ts";
import { Badge } from "../Badge";
function List({ children, className, groupTitle, ...rest }: ListProps) {
return groupTitle ? (
@ -86,6 +88,7 @@ function ListItem(props: ListItemProps) {
hasError,
rightControl,
rightControlVisibility = "always",
showUnsavedChanges,
size = "md",
startIcon,
title,
@ -159,6 +162,11 @@ function ListItem(props: ListItemProps) {
{rightControl}
</RightControlWrapper>
)}
{showUnsavedChanges ? (
<UnsavedChangesWrapper>
<Badge kind="info" size="small" />
</UnsavedChangesWrapper>
) : null}
</TopContentWrapper>
{isBlockDescription && (
<BottomContentWrapper data-isiconpresent={Boolean(startIcon)}>

View File

@ -38,6 +38,8 @@ export interface ListItemProps {
customTitleComponent?: ReactNode | ReactNode[];
/** dataTestId which will be used in automated tests */
dataTestId?: string;
/** Whether to show the unsaved changes indicator */
showUnsavedChanges?: boolean;
}
export interface ListProps {

View File

@ -7,6 +7,7 @@ import { EditableEntityName } from "../EditableEntityName";
import type { EditableDismissibleTabProps } from "./EditableDismissibleTab.types";
import { useActiveDoubleClick } from "../../__hooks__";
import { Badge } from "../../Badge";
export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
const {
@ -22,6 +23,7 @@ export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
onEnterEditMode: propOnEnterEditMode,
onExitEditMode: propOnExitEditMode,
onNameSave,
showUnsavedChanges,
validateName,
} = props;
@ -60,6 +62,7 @@ export const EditableDismissibleTab = (props: EditableDismissibleTabProps) => {
onNameSave={onNameSave}
validateName={validateName}
/>
{showUnsavedChanges ? <Badge kind="info" size="small" /> : null}
</DismissibleTab>
);
};

View File

@ -28,4 +28,6 @@ export interface EditableDismissibleTabProps {
onNameSave: (name: string) => void;
/** Function to validate the name. */
validateName: (name: string) => string | null;
/** Show unsaved changes indicator. */
showUnsavedChanges?: boolean;
}

View File

@ -0,0 +1,7 @@
import type { JSCollection } from "entities/JSCollection";
// Implementation exists in ee
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const getHighlightedLines = (jsCollection: JSCollection): number[] => {
return [];
};

View File

@ -454,6 +454,12 @@ export const getActions = (state: AppState): ActionDataState =>
export const getJSCollections = (state: AppState): JSCollectionDataState =>
state.entities.jsActions;
export const getAllJSCollectionActions = (state: AppState) => {
return state.entities.jsActions.flatMap(
(jsCollection) => jsCollection.config.actions,
);
};
export const getDatasource = (
state: AppState,
datasourceId: string,
@ -1752,3 +1758,40 @@ export const getIsSavingEntityName = (
return isSavingEntityName;
};
export const getActionSchemaDirtyState = createSelector(
getAction,
(state: AppState) =>
getPluginByPackageName(state, PluginPackageName.APPSMITH_AI),
(action, agentPlugin) => {
if (!action) return false;
if (agentPlugin?.id === action.pluginId) {
return false;
}
return action.isDirtyMap?.SCHEMA_GENERATION;
},
);
export const getJSCollectionSchemaDirtyState = createSelector(
(state: AppState, collectionId: string) =>
getJSCollection(state, collectionId),
(jsCollection) => {
if (!jsCollection) return false;
return jsCollection.actions.some(
(action) => action.isDirtyMap?.SCHEMA_GENERATION,
);
},
);
export const getJSCollectionActionSchemaDirtyState = createSelector(
(state: AppState, collectionId: string, actionId: string) =>
getJSCollectionAction(state, collectionId, actionId),
(action) => {
if (!action) return false;
return action.isDirtyMap?.SCHEMA_GENERATION;
},
);

View File

@ -260,6 +260,7 @@ export type EditorProps = EditorStyleProps &
removeHoverAndFocusStyle?: boolean;
customErrors?: LintError[];
highlightedLines?: number[]; // Array of line numbers to highlight
};
interface Props extends ReduxStateProps, EditorProps, ReduxDispatchProps {}
@ -445,11 +446,11 @@ class CodeEditor extends Component<Props, State> {
this: CodeEditor,
editor: CodeMirror.Editor,
) {
// If you need to do something with the editor right after its been created,
// If you need to do something with the editor right after it's been created,
// put that code here.
//
// This helps with performance: finishInit() is called inside
// CodeMirrors `operation()` (https://codemirror.net/doc/manual.html#operation
// CodeMirror's `operation()` (https://codemirror.net/doc/manual.html#operation
// which means CodeMirror recalculates itself only one time, once all CodeMirror
// changes here are completed
//
@ -500,7 +501,13 @@ class CodeEditor extends Component<Props, State> {
// Finally create the Codemirror editor
this.editor = CodeMirror(this.codeEditorTarget.current, options);
// DO NOT ADD CODE BELOW. If you need to do something with the editor right after its created,
// Add highlighting for initial render
if (this.props.highlightedLines?.length) {
this.updateLineHighlighting(this.props.highlightedLines);
}
// DO NOT ADD CODE BELOW. If you need to do something with the editor right after it's created,
// put that code into `options.finishInit()`.
}
@ -612,6 +619,11 @@ class CodeEditor extends Component<Props, State> {
}
}
// Check if highlighted lines have changed
if (!isEqual(prevProps.highlightedLines, this.props.highlightedLines)) {
this.updateLineHighlighting(this.props.highlightedLines || []);
}
this.editor.operation(() => {
const editorValue = this.editor.getValue();
// Safe update of value of the editor when value updated outside the editor
@ -1634,6 +1646,21 @@ class CodeEditor extends Component<Props, State> {
this.editor.setValue(value);
};
// Add new method to handle line highlighting
private updateLineHighlighting = (lines: number[]) => {
// Clear existing highlights
for (let i = 0; i < this.editor.lineCount(); i++) {
this.editor.removeLineClass(i, "background", "highlighted-line");
}
// Add new highlights
lines.forEach((lineNumber) => {
if (lineNumber >= 0 && lineNumber < this.editor.lineCount()) {
this.editor.addLineClass(lineNumber, "background", "highlighted-line");
}
});
};
render() {
const {
border,

View File

@ -256,6 +256,10 @@ export const EditorWrapper = styled.div<{
.CodeMirror-activeline-background {
background-color: #ececec;
}
.highlighted-line {
background-color: rgba(255, 255, 0, 0.2);
}
}
.CodeMirror-guttermarker-subtle {
color: var(--ads-v2-color-fg-subtle);

View File

@ -0,0 +1 @@
export { getHighlightedLines } from "ce/pages/Editor/JSEditor/utils/getHighlightedLines";

View File

@ -1,7 +1,10 @@
import React, { useCallback, useMemo } from "react";
import { EntityItem, EntityContextMenu } from "@appsmith/ads";
import type { AppState } from "ee/reducers";
import { getJsCollectionByBaseId } from "ee/selectors/entitiesSelector";
import {
getJsCollectionByBaseId,
getJSCollectionSchemaDirtyState,
} from "ee/selectors/entitiesSelector";
import { useDispatch, useSelector } from "react-redux";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
@ -105,6 +108,10 @@ export const JSEntityItem = ({ item }: { item: EntityItemProps }) => {
validateName,
]);
const isJSActionSchemaDirty = useSelector((state: AppState) =>
getJSCollectionSchemaDirtyState(state, item.key),
);
return (
<EntityItem
className={clsx("t--jsaction", {
@ -118,6 +125,7 @@ export const JSEntityItem = ({ item }: { item: EntityItemProps }) => {
onDoubleClick={() => enterEditMode(jsAction.id)}
rightControl={contextMenu}
rightControlVisibility="hover"
showUnsavedChanges={isJSActionSchemaDirty}
startIcon={JsFileIconV2(16, 16)}
title={item.title}
/>

View File

@ -3,6 +3,7 @@ import { EntityItem, EntityContextMenu } from "@appsmith/ads";
import type { AppState } from "ee/reducers";
import {
getActionByBaseId,
getActionSchemaDirtyState,
getDatasource,
getPlugins,
} from "ee/selectors/entitiesSelector";
@ -117,6 +118,10 @@ export const QueryEntityItem = ({ item }: { item: EntityItemProps }) => {
validateName,
]);
const isActionSchemaDirty = useSelector((state: AppState) =>
getActionSchemaDirtyState(state, action.id),
);
return (
<EntityItem
className="action t--action-entity"
@ -128,6 +133,7 @@ export const QueryEntityItem = ({ item }: { item: EntityItemProps }) => {
onDoubleClick={() => enterEditMode(action.id)}
rightControl={contextMenu}
rightControlVisibility="hover"
showUnsavedChanges={isActionSchemaDirty}
startIcon={icon}
title={item.title}
/>

View File

@ -6,7 +6,11 @@ import { EditableDismissibleTab } from "@appsmith/ads";
import { type EntityItem } from "ee/IDE/Interfaces/EntityItem";
import { FEATURE_FLAG } from "ee/entities/FeatureFlag";
import { getIsSavingEntityName } from "ee/selectors/entitiesSelector";
import {
getActionSchemaDirtyState,
getIsSavingEntityName,
getJSCollectionSchemaDirtyState,
} from "ee/selectors/entitiesSelector";
import { useFeatureFlag } from "utils/hooks/useFeatureFlag";
import { sanitizeString } from "utils/URLUtils";
@ -61,6 +65,16 @@ export function EditableTab(props: EditableTabProps) {
[dispatch, entity, id, segment],
);
const isJSActionSchemaDirty = useSelector((state) =>
getJSCollectionSchemaDirtyState(state, id),
);
const isActionSchemaDirty = useSelector((state) =>
getActionSchemaDirtyState(state, id),
);
const isSchemaDirty = isJSActionSchemaDirty || isActionSchemaDirty;
return (
<EditableDismissibleTab
dataTestId={`t--ide-tab-${sanitizeString(title)}`}
@ -75,6 +89,7 @@ export function EditableTab(props: EditableTabProps) {
onEnterEditMode={enterEditMode}
onExitEditMode={exitEditMode}
onNameSave={handleNameSave}
showUnsavedChanges={isSchemaDirty}
validateName={validateName}
/>
);

View File

@ -57,6 +57,7 @@ import {
getJSActionOption,
type OnUpdateSettingsProps,
} from "./JSEditorToolbar";
import { getHighlightedLines } from "ee/pages/Editor/JSEditor/utils/getHighlightedLines";
interface JSFormProps {
jsCollectionData: JSCollectionData;
@ -287,6 +288,8 @@ function JSEditorForm({
}
};
const highlightedLines = getHighlightedLines(currentJSCollection);
useEffect(() => {
if (parseErrors.length || isEmpty(jsActions)) {
setDisableRunFunctionality(true);
@ -373,6 +376,7 @@ function JSEditorForm({
currentJSCollection={currentJSCollection}
customGutter={JSGutters}
executing={isExecutingCurrentJSAction}
highlightedLines={highlightedLines}
onChange={handleEditorChange}
onUpdateSettings={onUpdateSettings}
onValueChange={(string) =>

View File

@ -16,6 +16,7 @@ import { Flex } from "@appsmith/ads";
interface Props {
executing: boolean;
highlightedLines?: number[];
onValueChange: (value: string) => void;
value: JSEditorTab;
showSettings: undefined | boolean;
@ -44,6 +45,7 @@ export const JSEditorForm = (props: Props) => {
folding
height={"100%"}
hideEvaluatedValue
highlightedLines={props.highlightedLines}
input={{
value: props.currentJSCollection.body,
onChange: props.onChange,

View File

@ -12,7 +12,6 @@ import AppJSEditorContextMenu from "./AppJSEditorContextMenu";
import { updateFunctionProperty } from "actions/jsPaneActions";
import type { OnUpdateSettingsProps } from "./JSEditorToolbar";
import { saveJSObjectName } from "actions/jsActionActions";
const LoadingContainer = styled(CenteredWrapper)`
height: 50%;
`;