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:
parent
e3a49845e7
commit
074fe6fd37
|
|
@ -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]}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 it’s 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
|
||||
// CodeMirror’s `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 it’s 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { getHighlightedLines } from "ce/pages/Editor/JSEditor/utils/getHighlightedLines";
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user