Repository: aspyn-io/uikit
Pull request #80: Enhance JsonViewer with Internal State Management
Author: login: ivanvasechko
Created: 2025-03-27T13:24:43Z | Merged: 2025-03-27T15:14:26Z

Closes #79 

This pull request includes several enhancements to the `JsonViewer` component, making it more flexible and improving its usability. The most important changes include introducing an optional controlled state for the modal, updating the `JsonViewer` props, and adding new stories to demonstrate the controlled behavior.

Enhancements to `JsonViewer` component:

* [`src/components/JsonViewer.tsx`](diffhunk://#diff-25b172b5a5ec9743ffe3f9c906c9238f94073e0a5e960ac53d108c9acff23f48L1-R11): Added `useState` import and introduced optional controlled state for the modal with `isOpen` and `onOpenChange` props. Updated the component to handle both controlled and uncontrolled states. [[1]](diffhunk://#diff-25b172b5a5ec9743ffe3f9c906c9238f94073e0a5e960ac53d108c9acff23f48L1-R11) [[2]](diffhunk://#diff-25b172b5a5ec9743ffe3f9c906c9238f94073e0a5e960ac53d108c9acff23f48L22-R47)

Updates to `JsonViewerModal`:

* [`src/components/JsonViewerModal.tsx`](diffhunk://#diff-3f4526d67e68318a4b14911fc969ec147144fe72403261d1015b28cbe43a4aacL3): Removed unused `HiClipboard` import to clean up the code.

Storybook updates:

* [`src/stories/JsonViewer.stories.tsx`](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00L17-R18): Updated the `title` prop to be required and added descriptions for the new `isOpen` and `onOpenChange` props. [[1]](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00L17-R18) [[2]](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00R42-R48)
* [`src/stories/JsonViewer.stories.tsx`](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00R66-R79): Added a new template and story to demonstrate the controlled state behavior of the `JsonViewer` component. [[1]](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00R66-R79) [[2]](diffhunk://#diff-9f785dfecd5eb3194b67a89d343d58d264ee6a74c6a8374dc76381365fbddc00R93-R101)

Linked issue #79: Enhance JsonViewer with Internal State Management
### Description
Most of the time, consumers of the `JsonViewer` component do not need to manage its state manually. To improve usability, the component should handle its own open/close state by default while still allowing external control when necessary.

### Requirements
- Implement internal state management for modal visibility.
- Allow consumers to override the state if needed via props.
- Ensure backward compatibility with existing implementations.

Diff:
diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx
index 71a5e967..7cae7cc4 100644
--- a/src/components/JsonViewer.tsx
+++ b/src/components/JsonViewer.tsx
@@ -1,41 +1,68 @@
-import { FC, ReactNode } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { Button, ButtonProps } from 'flowbite-react';
 import JsonViewerModal from './JsonViewerModal';
 
-export interface JsonViewerProps {
+interface BaseJsonViewerProps {
   data: any;
-  title?: string;
+  title: string;
   buttonLabel?: ReactNode;
   buttonProps?: ButtonProps;
-  isOpen: boolean;
-  onOpenChange: (open: boolean) => void;
-  dismissible?: boolean;
   invalidDataMessage?: string;
   allowCopy?: boolean;
 }
 
+interface UncontrolledJsonViewerProps extends BaseJsonViewerProps {
+  show?: undefined;
+  onClose?: undefined;
+}
+
+interface ControlledJsonViewerProps extends BaseJsonViewerProps {
+  show: boolean;
+  onClose: () => void;
+}
+
+export type JsonViewerProps = ControlledJsonViewerProps | UncontrolledJsonViewerProps;
+
 const JsonViewer: FC<JsonViewerProps> = ({
   data,
   title,
   buttonLabel = 'View JSON',
   buttonProps,
-  isOpen,
-  onOpenChange,
-  dismissible,
+  show: controlledShow,
+  onClose,
   invalidDataMessage,
   allowCopy,
 }) => {
+  const [uncontrolledShow, setUncontrolledShow] = useState(false);
+  
+  const isControlled = controlledShow !== undefined;
+  const modalShow = isControlled ? controlledShow : uncontrolledShow;
+  
+  const handleClose = () => {
+    if (!isControlled) {
+      setUncontrolledShow(false);
+    }
+    onClose?.();
+  };
+
+  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+    if (isControlled) {
+      buttonProps?.onClick?.(event);
+    } else {
+      setUncontrolledShow(true);
+    }
+  };
+
   return (
     <>
-      <Button {...buttonProps} onClick={() => onOpenChange(true)}>
+      <Button {...buttonProps} onClick={handleClick}>
         {buttonLabel}
       </Button>
       <JsonViewerModal
-        isOpen={isOpen}
-        onClose={() => onOpenChange(false)}
+        show={modalShow}
+        onClose={handleClose}
         title={title}
         data={data}
-        dismissible={dismissible}
         invalidDataMessage={invalidDataMessage}
         allowCopy={allowCopy}
       />
diff --git a/src/components/JsonViewerModal.tsx b/src/components/JsonViewerModal.tsx
index 93ddc31c..1d18bfed 100644
--- a/src/components/JsonViewerModal.tsx
+++ b/src/components/JsonViewerModal.tsx
@@ -1,23 +1,21 @@
 import { FC } from 'react';
-import { Modal, Button, Clipboard } from 'flowbite-react';
-import { HiClipboard } from 'react-icons/hi';
+import { Button, Clipboard } from 'flowbite-react';
+import Modal from './Modal';
 
 export interface JsonViewerModalProps {
-  isOpen: boolean;
+  show: boolean; 
   onClose: () => void;
   title?: string;
   data: any;
-  dismissible?: boolean;
   invalidDataMessage?: string;
   allowCopy?: boolean;
 }
 
 const JsonViewerModal: FC<JsonViewerModalProps> = ({
-  isOpen,
+  show,
   onClose,
   title = 'JSON Data Viewer',
   data,
-  dismissible = true,
   invalidDataMessage = 'Unable to display data: Invalid or circular JSON structure',
   allowCopy = true,
 }) => {
@@ -32,7 +30,7 @@ const JsonViewerModal: FC<JsonViewerModalProps> = ({
   const formattedJson = getFormattedJson();
 
   return (
-    <Modal show={isOpen} onClose={onClose} size="4xl" dismissible={dismissible}>
+    <Modal show={show} onClose={onClose} size="4xl">
       <Modal.Header>{title}</Modal.Header>
       <Modal.Body className="pl-5 pr-5 pt-2 pb-2">
         <div className="relative">
diff --git a/src/stories/JsonViewer.stories.tsx b/src/stories/JsonViewer.stories.tsx
index 5f398fd1..ab6b0e42 100644
--- a/src/stories/JsonViewer.stories.tsx
+++ b/src/stories/JsonViewer.stories.tsx
@@ -8,26 +8,29 @@ const meta: Meta<typeof JsonViewer> = {
   component: JsonViewer,
   tags: ['autodocs'],
   argTypes: {
-    buttonLabel: {
-      control: 'text',
-      description: 'Text to display on the button',
+    data: {
+      control: 'object',
+      description: 'JSON data to display',
     },
     title: {
       control: 'text',
       description: 'Title for the modal',
+      required: true,
     },
-    data: {
-      control: 'object',
-      description: 'JSON data to display',
+    buttonLabel: {
+      control: 'text',
+      description: 'Text to display on the button',
     },
     buttonProps: {
       control: 'object',
       description: 'Props to pass to the button component',
     },
-    dismissible: {
+    show: {
       control: 'boolean',
-      description: 'Whether the modal can be dismissed by clicking outside or pressing ESC',
-      defaultValue: true,
+      description: 'Optional controlled state for the modal. If not provided, the component manages its own state.',
+    },
+    onClose: {
+      description: 'Optional callback fired when the modal closes. Required if show is provided.',
     },
     invalidDataMessage: {
       control: 'text',
@@ -55,15 +58,20 @@ const sampleData = {
 
 // Template component to handle state
 const JsonViewerTemplate: React.FC<{ args: any }> = ({ args }) => {
-  const [isOpen, setIsOpen] = useState(false);
+  return (
+    <div className="p-4">
+      <JsonViewer {...args} />
+    </div>
+  );
+};
+
+// Add a template for controlled behavior demonstration
+const ControlledJsonViewerTemplate: React.FC<{ args: any }> = ({ args }) => {
+  const [show, setShow] = useState(false);
 
   return (
     <div className="p-4">
-      <JsonViewer
-        {...args}
-        isOpen={isOpen}
-        onOpenChange={setIsOpen}
-      />
+      <JsonViewer {...args} show={show} onClose={() => setShow(false)} />
     </div>
   );
 };
@@ -77,6 +85,30 @@ export const Default: Story = {
   },
 };
 
+export const ControlledState: Story = {
+  render: function ControlledStateStory(args) {
+    const [show, setShow] = useState(false);
+    return (
+      <div className="p-4">
+        <JsonViewer
+          {...args}
+          show={show}
+          onClose={() => setShow(false)}
+          buttonProps={{
+            onClick: () => setShow(true),
+            ...args.buttonProps
+          }}
+        />
+      </div>
+    );
+  },
+  args: {
+    data: sampleData,
+    title: 'Controlled Modal Example',
+    buttonLabel: 'Open Controlled Modal',
+  },
+};
+
 export const WithIcon: Story = {
   render: (args) => <JsonViewerTemplate args={args} />,
   args: {
@@ -109,19 +141,6 @@ const circularObject: any = {
 };
 circularObject.self = circularObject;
 
-export const NonDismissibleModal: Story = {
-  render: (args) => <JsonViewerTemplate args={args} />,
-  args: {
-    data: sampleData,
-    title: 'Non-dismissible Modal',
-    buttonLabel: 'Open Non-dismissible Modal',
-    buttonProps: {
-      color: 'warning',
-    },
-    dismissible: false,
-  },
-};
-
 export const InvalidData: Story = {
   render: (args) => <JsonViewerTemplate args={args} />,
   args: {
diff --git a/src/stories/JsonViewerModal.stories.tsx b/src/stories/JsonViewerModal.stories.tsx
index 8ebc4136..b922a815 100644
--- a/src/stories/JsonViewerModal.stories.tsx
+++ b/src/stories/JsonViewerModal.stories.tsx
@@ -17,7 +17,7 @@ const meta: Meta<typeof JsonViewerModal> = {
   },
   tags: ['autodocs'],
   argTypes: {
-    isOpen: {
+    show: {
       control: 'boolean',
       description: 'Controls whether the modal is open or closed',
     },
@@ -33,11 +33,6 @@ const meta: Meta<typeof JsonViewerModal> = {
       action: 'closed',
       description: 'Callback function when the modal is closed',
     },
-    dismissible: {
-      control: 'boolean',
-      description: 'Controls whether the modal can be closed by clicking outside or using ESC key',
-      defaultValue: true,
-    },
     invalidDataMessage: {
       control: 'text',
       description: 'Custom message to display when JSON data cannot be stringified',
@@ -65,15 +60,15 @@ const sampleData = {
 };
 
 const JsonViewerModalTemplate: React.FC<{ args: any }> = ({ args }) => {
-  const [isOpen, setIsOpen] = useState(false);
+  const [show, setShow] = useState(false);
 
   return (
     <div className="p-4">
-      <Button onClick={() => setIsOpen(true)}>View JSON Data</Button>
+      <Button onClick={() => setShow(true)}>View JSON Data</Button>
       <JsonViewerModal
         {...args}
-        isOpen={isOpen}
-        onClose={() => setIsOpen(false)}
+        show={show}
+        onClose={() => setShow(false)}
       />
     </div>
   );
@@ -100,15 +95,6 @@ const circularObject: any = {
 };
 circularObject.self = circularObject;
 
-export const NonDismissible: Story = {
-  render: (args) => <JsonViewerModalTemplate args={args} />,
-  args: {
-    title: 'Non-dismissible Modal',
-    data: sampleData,
-    dismissible: false,
-  },
-};
-
 export const CustomMessageForInvalidData: Story = {
   render: (args) => <JsonViewerModalTemplate args={args} />,
   args: {


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR enhances the JsonViewer component by adding optional controlled state management for the modal and updating its associated stories.  
- Introduces controlled state using optional isOpen and onOpenChange props in JsonViewer.tsx.  
- Updates Storybook stories to document and demonstrate the controlled behavior.  
- Cleans up JsonViewerModal.tsx by removing the unused HiClipboard import.

### Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

| File                                    | Description                                                             |
| --------------------------------------- | ----------------------------------------------------------------------- |
| src/stories/JsonViewer.stories.tsx      | Updated props documentation and added a controlled behavior story.      |
| src/components/JsonViewerModal.tsx      | Removed unused import to streamline the code.                           |
| src/components/JsonViewer.tsx             | Implemented internal state management to support both controlled & uncontrolled modes. |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:46:42Z
user: login: ivanvasechko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:50:44Z
user: login: syntaqx
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T15:14:25Z

Review comments:
diff_hunk: @@ -1,14 +1,14 @@
-import { FC, ReactNode } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { Button, ButtonProps } from 'flowbite-react';
 import JsonViewerModal from './JsonViewerModal';
 
 export interface JsonViewerProps {
   data: any;
-  title?: string;
+  title: string;
   buttonLabel?: ReactNode;
   buttonProps?: ButtonProps;
-  isOpen: boolean;
-  onOpenChange: (open: boolean) => void;
+  isOpen?: boolean;
path: src/components/JsonViewer.tsx
user: login: Copilot
body: The component documentation indicates that onOpenChange should be required when isOpen is provided, but the prop is optional here. Consider updating the types to enforce that onOpenChange is provided in controlled mode to avoid potential misuse.
created_at: 2025-03-27T13:46:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,14 +1,14 @@
-import { FC, ReactNode } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { Button, ButtonProps } from 'flowbite-react';
 import JsonViewerModal from './JsonViewerModal';
 
 export interface JsonViewerProps {
   data: any;
-  title?: string;
+  title: string;
   buttonLabel?: ReactNode;
   buttonProps?: ButtonProps;
-  isOpen: boolean;
-  onOpenChange: (open: boolean) => void;
+  isOpen?: boolean;
path: src/components/JsonViewer.tsx
user: login: ivanvasechko
body: Fixed in https://github.com/aspyn-io/uikit/pull/80/commits/7a56bfe3d8bbef298eec29ef99cd2933a1e6f907
created_at: 2025-03-27T13:50:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: syntaqx
created_at: 2025-03-27T14:54:08Z
body: Didn't see this in a previous PR, but looks like the JSONViewerModal isn't extending the modal from uikit, just from flowbites, can we add that in this PR too?
author_association: CONTRIBUTOR
user: login: ivanvasechko
created_at: 2025-03-27T14:55:12Z
body: > Didn't see this in a previous PR, but looks like the JSONViewerModal isn't extending the modal from uikit, just from flowbites, can we add that in this PR too?

sure thing
author_association: CONTRIBUTOR
user: login: ivanvasechko
created_at: 2025-03-27T15:13:20Z
body: @syntaqx updated
author_association: CONTRIBUTOR