From cce3bfa5ec40794b7fcb91f28ac0027dbab1630c Mon Sep 17 00:00:00 2001 From: Nicholas VanCise <40526638+thenick775@users.noreply.github.com> Date: Fri, 20 Sep 2024 18:21:26 -0700 Subject: [PATCH] feat: control profiles (#173) * feat: controls profile form - wip control profile form form poc * feat: wip design - not sure this is the best application of this style of button list * feat: show behavior * feat: edit name - not sure I like this iteration, but a decent idea * feat: tentative edit impl * feat: editable profile name * feat: remove debug info, fix form submits * feat: hoist shared icon * feat: independent profiles with same name * fix: zooming behavior * feat: tests --- gbajs3/package-lock.json | 46 +++- gbajs3/package.json | 1 + gbajs3/src/components/controls/consts.tsx | 1 + .../controls/control-panel.spec.tsx | 1 + gbajs3/src/components/modals/cheats.tsx | 7 +- .../src/components/modals/controls.spec.tsx | 34 ++- gbajs3/src/components/modals/controls.tsx | 51 +++- .../modals/controls/control-profiles.spec.tsx | 137 ++++++++++ .../modals/controls/control-profiles.tsx | 242 ++++++++++++++++++ gbajs3/src/components/modals/save-states.tsx | 9 +- gbajs3/src/components/shared/styled.tsx | 6 + gbajs3/src/context/layout/layout.tsx | 8 +- gbajs3/src/hooks/use-layouts.tsx | 10 +- 13 files changed, 509 insertions(+), 44 deletions(-) create mode 100644 gbajs3/src/components/modals/controls/control-profiles.spec.tsx create mode 100644 gbajs3/src/components/modals/controls/control-profiles.tsx diff --git a/gbajs3/package-lock.json b/gbajs3/package-lock.json index 741f3376..f7a1e0f9 100644 --- a/gbajs3/package-lock.json +++ b/gbajs3/package-lock.json @@ -14,6 +14,7 @@ "@mui/x-tree-view": "^7.0.0", "@uidotdev/usehooks": "^2.4.1", "jwt-decode": "^4.0.0", + "nanoid": "^5.0.7", "react": "^18.2.0", "react-animate-height": "^3.2.2", "react-dom": "^18.2.0", @@ -8177,9 +8178,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz", + "integrity": "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==", "funding": [ { "type": "github", @@ -8187,10 +8188,10 @@ } ], "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -8599,6 +8600,23 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -10620,6 +10638,24 @@ } } }, + "node_modules/vite/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/vite/node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", diff --git a/gbajs3/package.json b/gbajs3/package.json index 5b96e088..fc1a7b5c 100644 --- a/gbajs3/package.json +++ b/gbajs3/package.json @@ -19,6 +19,7 @@ "@mui/x-tree-view": "^7.0.0", "@uidotdev/usehooks": "^2.4.1", "jwt-decode": "^4.0.0", + "nanoid": "^5.0.7", "react": "^18.2.0", "react-animate-height": "^3.2.2", "react-dom": "^18.2.0", diff --git a/gbajs3/src/components/controls/consts.tsx b/gbajs3/src/components/controls/consts.tsx index d1d4bc67..2cf207d0 100644 --- a/gbajs3/src/components/controls/consts.tsx +++ b/gbajs3/src/components/controls/consts.tsx @@ -1,2 +1,3 @@ export const saveStateSlotLocalStorageKey = 'currentSaveStateSlot'; export const virtualControlsLocalStorageKey = 'areVirtualControlsEnabled'; +export const virtualControlProfilesLocalStorageKey = 'virtualControlProfiles'; diff --git a/gbajs3/src/components/controls/control-panel.spec.tsx b/gbajs3/src/components/controls/control-panel.spec.tsx index b3d16a00..6cb56550 100644 --- a/gbajs3/src/components/controls/control-panel.spec.tsx +++ b/gbajs3/src/components/controls/control-panel.spec.tsx @@ -166,6 +166,7 @@ describe('', () => { const testLayout = { clearLayouts: vi.fn(), setLayout: setLayoutSpy, + setLayouts: vi.fn(), hasSetLayout: true, layouts: { screen: { initialBounds: new DOMRect() } } }; diff --git a/gbajs3/src/components/modals/cheats.tsx b/gbajs3/src/components/modals/cheats.tsx index faa14d16..b3339f78 100644 --- a/gbajs3/src/components/modals/cheats.tsx +++ b/gbajs3/src/components/modals/cheats.tsx @@ -1,7 +1,6 @@ import { Button, IconButton, TextField, useMediaQuery } from '@mui/material'; import { useCallback, useId, useMemo, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; -import { BiPlus } from 'react-icons/bi'; import { CiSquareRemove } from 'react-icons/ci'; import { styled, useTheme } from 'styled-components'; @@ -15,6 +14,7 @@ import { } from '../product-tour/embedded-product-tour.tsx'; import { CircleCheckButton } from '../shared/circle-check-button.tsx'; import { ManagedCheckbox } from '../shared/managed-checkbox.tsx'; +import { StyledBiPlus } from '../shared/styled.tsx'; type OptionallyHiddenProps = { $shouldHide: boolean; @@ -54,11 +54,6 @@ const StyledCiSquareRemove = styled(CiSquareRemove)` min-width: 40px; `; -const StyledBiPlus = styled(BiPlus)` - width: 25px; - height: 25px; -`; - const CheatsFormSeparator = styled.div` display: flex; flex-direction: column; diff --git a/gbajs3/src/components/modals/controls.spec.tsx b/gbajs3/src/components/modals/controls.spec.tsx index 14fbe162..c2792805 100644 --- a/gbajs3/src/components/modals/controls.spec.tsx +++ b/gbajs3/src/components/modals/controls.spec.tsx @@ -15,6 +15,9 @@ describe('', () => { expect( screen.getByRole('tab', { name: 'Virtual Controls', selected: true }) ).toBeVisible(); + expect( + screen.getByRole('tab', { name: 'Profiles', selected: false }) + ).toBeVisible(); expect( screen.getByRole('tab', { name: 'Key Bindings', selected: false }) ).toBeVisible(); @@ -27,12 +30,34 @@ describe('', () => { screen.getByRole('button', { name: 'Save Changes' }).getAttribute('form') ); + // select control profiles + await userEvent.click(screen.getByRole('tab', { name: 'Profiles' })); + + expect( + screen.getByRole('tab', { name: 'Profiles', selected: true }) + ).toBeVisible(); + expect( + screen.getByRole('tab', { name: 'Key Bindings', selected: false }) + ).toBeVisible(); + expect( + screen.getByRole('tab', { name: 'Virtual Controls', selected: false }) + ).toBeVisible(); + + expect(screen.getByRole('list', { name: 'Profiles List' })).toBeVisible(); + // note: save changes button is not shown on control profiles tab + expect( + screen.queryByRole('button', { name: 'Save Changes' }) + ).not.toBeInTheDocument(); + // select key bindings form await userEvent.click(screen.getByRole('tab', { name: 'Key Bindings' })); expect( screen.getByRole('tab', { name: 'Key Bindings', selected: true }) ).toBeVisible(); + expect( + screen.getByRole('tab', { name: 'Profiles', selected: false }) + ).toBeVisible(); expect( screen.getByRole('tab', { name: 'Virtual Controls', selected: false }) ).toBeVisible(); @@ -53,15 +78,18 @@ describe('', () => { expect( screen.getByRole('tab', { name: 'Virtual Controls', selected: true }) ).toBeVisible(); + expect( + screen.getByRole('tab', { name: 'Profiles', selected: false }) + ).toBeVisible(); expect( screen.getByRole('tab', { name: 'Key Bindings', selected: false }) ).toBeVisible(); - const virtualControlsFormRenavigate = screen.getByRole('form', { + const virtualControlsFormReNavigate = screen.getByRole('form', { name: 'Virtual Controls Form' }); - expect(virtualControlsFormRenavigate).toBeVisible(); - expect(virtualControlsFormRenavigate.id).toEqual( + expect(virtualControlsFormReNavigate).toBeVisible(); + expect(virtualControlsFormReNavigate.id).toEqual( screen.getByRole('button', { name: 'Save Changes' }).getAttribute('form') ); }); diff --git a/gbajs3/src/components/modals/controls.tsx b/gbajs3/src/components/modals/controls.tsx index 1a54806f..6b405a8f 100644 --- a/gbajs3/src/components/modals/controls.tsx +++ b/gbajs3/src/components/modals/controls.tsx @@ -13,6 +13,7 @@ import { type TourSteps } from '../product-tour/embedded-product-tour.tsx'; import { CircleCheckButton } from '../shared/circle-check-button.tsx'; +import { ControlProfiles } from './controls/control-profiles.tsx'; type TabPanelProps = { children: ReactNode; @@ -23,6 +24,7 @@ type TabPanelProps = { type ControlTabsProps = { setFormId: Dispatch>; virtualControlsFormId: string; + controlProfilesFormId: string; keyBindingsFormId: string; resetPositionsButtonId: string; setIsSuccessfulSubmit: (successfulSubmit: boolean) => void; @@ -31,6 +33,10 @@ type ControlTabsProps = { const TabsWithBorder = styled(Tabs)` border-bottom: 1px solid; border-color: rgba(0, 0, 0, 0.12); + + & .MuiTabs-scrollButtons { + width: fit-content; + } `; const TabWrapper = styled.div` @@ -60,6 +66,7 @@ const TabPanel = ({ children, index, value }: TabPanelProps) => { const ControlTabs = ({ setFormId, virtualControlsFormId, + controlProfilesFormId, keyBindingsFormId, resetPositionsButtonId, setIsSuccessfulSubmit @@ -67,9 +74,22 @@ const ControlTabs = ({ const { clearLayouts } = useLayoutContext(); const [value, setValue] = useState(0); - const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { - setValue(newValue); - setFormId(newValue === 0 ? virtualControlsFormId : keyBindingsFormId); + const tabIndexToFormId = (tabIndex: number) => { + switch (tabIndex) { + case 0: + return virtualControlsFormId; + case 1: + return controlProfilesFormId; + case 2: + return keyBindingsFormId; + default: + return virtualControlsFormId; + } + }; + + const handleTabChange = (_: React.SyntheticEvent, tabIndex: number) => { + setValue(tabIndex); + setFormId(tabIndexToFormId(tabIndex)); setIsSuccessfulSubmit(false); }; @@ -78,12 +98,15 @@ const ControlTabs = ({ return ( <> - + + + + + @@ -175,19 +201,22 @@ export const ControlsModal = () => { - + {formId !== `${baseId}--control-profiles` && ( + + )} diff --git a/gbajs3/src/components/modals/controls/control-profiles.spec.tsx b/gbajs3/src/components/modals/controls/control-profiles.spec.tsx new file mode 100644 index 00000000..8c676c1f --- /dev/null +++ b/gbajs3/src/components/modals/controls/control-profiles.spec.tsx @@ -0,0 +1,137 @@ +import { screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ControlProfiles } from './control-profiles.tsx'; +import { renderWithContext } from '../../../../test/render-with-context.tsx'; +import * as contextHooks from '../../../hooks/context.tsx'; +import { virtualControlProfilesLocalStorageKey } from '../../controls/consts.tsx'; + +describe('', () => { + it('renders if there are no profiles', () => { + renderWithContext(); + + expect(screen.getByText('No control profiles')); + expect( + screen.getByRole('button', { name: 'Create New Profile' }) + ).toBeVisible(); + }); + + it('renders profiles from storage', async () => { + localStorage.setItem( + virtualControlProfilesLocalStorageKey, + '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true},{"id":"testId2","name":"Profile-2","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]' + ); + + renderWithContext(); + + // isValid from RHF updates state, but its not visible as we are not editing, we need to wait for it + await waitFor(() => + expect( + screen.getByRole('button', { name: "Edit Profile-1's name" }) + ).toHaveAttribute('data-is-valid', 'true') + ); + + expect(screen.getByRole('button', { name: 'Profile-1' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Profile-2' })).toBeVisible(); + expect(screen.getByRole('button', { name: "Edit Profile-1's name" })); + expect(screen.getByRole('button', { name: "Edit Profile-2's name" })); + expect(screen.getByRole('button', { name: 'Delete Profile-1' })); + expect(screen.getByRole('button', { name: 'Delete Profile-2' })); + }); + + it('loads a profile', async () => { + localStorage.setItem( + virtualControlProfilesLocalStorageKey, + '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]' + ); + + const setLayoutsSpy = vi.fn(); + + const { useLayoutContext: originalLayout } = await vi.importActual< + typeof contextHooks + >('../../../hooks/context.tsx'); + + vi.spyOn(contextHooks, 'useLayoutContext').mockImplementation(() => ({ + ...originalLayout(), + setLayouts: setLayoutsSpy + })); + + renderWithContext(); + + await userEvent.click(screen.getByRole('button', { name: 'Profile-1' })); + + expect(setLayoutsSpy).toHaveBeenCalledWith({ + controlPanel: expect.anything(), + screen: expect.anything() + }); + }); + + it('adds new profiles', async () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + + renderWithContext(); + + await userEvent.click(screen.getByLabelText('Create New Profile')); + + expect(screen.getByRole('button', { name: 'Profile-1' })); + + expect(setItemSpy).toHaveBeenCalledWith( + virtualControlProfilesLocalStorageKey, + expect.stringMatching( + /\[{"id":".*","name":"Profile-1","layouts":{},"active":true}\]/ + ) + ); + + await userEvent.click(screen.getByLabelText('Create New Profile')); + + expect(screen.getByRole('button', { name: 'Profile-2' })); + + expect(setItemSpy).toHaveBeenCalledWith( + virtualControlProfilesLocalStorageKey, + expect.stringMatching( + /\[{"id":".*","name":"Profile-1","layouts":{},"active":true},{"id":".*","name":"Profile-2","layouts":{},"active":true}\]/ + ) + ); + }); + + it('updates a profile name', async () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); + localStorage.setItem( + virtualControlProfilesLocalStorageKey, + '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true},{"id":"testId2","name":"Profile-2","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]' + ); + + renderWithContext(); + + await userEvent.click( + screen.getByRole('button', { name: "Edit Profile-1's name" }) + ); + + await userEvent.type(screen.getByRole('textbox'), '-edited'); + + await userEvent.click( + screen.getByRole('button', { name: "Save Profile-1's name" }) + ); + + expect(screen.getByRole('button', { name: 'Profile-1-edited' })); + + expect(setItemSpy).toHaveBeenCalledWith( + virtualControlProfilesLocalStorageKey, + expect.stringMatching(/\[{"id":"testId1","name":"Profile-1-edited".*\]/) + ); + }); + + it('deletes a profile', async () => { + localStorage.setItem( + virtualControlProfilesLocalStorageKey, + '[{"id":"testId1","name":"Profile-1","layouts":{"screen":{"initialBounds":{"x":260,"y":15,"width":834,"height":600.09375,"top":15,"right":1094,"bottom":615.09375,"left":260}},"controlPanel":{"initialBounds":{"x":260,"y":620,"width":584,"height":60,"top":620,"right":844,"bottom":680,"left":260}}},"active":true}]' + ); + + renderWithContext(); + + await userEvent.click(screen.getByLabelText('Delete Profile-1')); + + expect(screen.getByText('No control profiles')); + }); +}); diff --git a/gbajs3/src/components/modals/controls/control-profiles.tsx b/gbajs3/src/components/modals/controls/control-profiles.tsx new file mode 100644 index 00000000..e80f8f98 --- /dev/null +++ b/gbajs3/src/components/modals/controls/control-profiles.tsx @@ -0,0 +1,242 @@ +import { IconButton, TextField } from '@mui/material'; +import { useLocalStorage } from '@uidotdev/usehooks'; +import { nanoid } from 'nanoid'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { BiTrash, BiEdit, BiSave } from 'react-icons/bi'; +import { styled } from 'styled-components'; + +import { useLayoutContext } from '../../../hooks/context.tsx'; +import { virtualControlProfilesLocalStorageKey } from '../../controls/consts.tsx'; +import { CenteredText, StyledBiPlus } from '../../shared/styled.tsx'; + +import type { Layouts } from '../../../context/layout/layout.tsx'; +import type { IconButtonProps } from '@mui/material'; +import type { ReactNode } from 'react'; + +type ControlProfilesProps = { + id: string; +}; + +type VirtualControlProfile = { + id: string; + name: string; + active: boolean; + layouts: Layouts; +}; + +type VirtualControlProfiles = VirtualControlProfile[]; + +type StatefulIconButtonProps = { + condition: boolean; + truthyIcon: ReactNode; + falsyIcon: ReactNode; +} & IconButtonProps; + +type EditableProfileLoadButtonProps = { + name: string; + loadProfile: () => void; + onSubmit: ({ name }: { name: string }) => void; +}; + +const StyledLi = styled.li` + cursor: pointer; + display: grid; + grid-template-columns: auto 32px; + gap: 10px; + + color: ${({ theme }) => theme.blueCharcoal}; + background-color: ${({ theme }) => theme.pureWhite}; + border: 1px solid rgba(0, 0, 0, 0.125); +`; + +const ProfilesList = styled.ul` + list-style-type: none; + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + + & > ${StyledLi}:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + & > ${StyledLi}:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + + & > ${StyledLi}:not(:first-child) { + border-top-width: 0; + } +`; + +const StyledCiCircleRemove = styled(BiTrash)` + height: 100%; + width: 20px; +`; + +const StyledBiEdit = styled(BiEdit)` + height: 100%; + width: 20px; +`; + +const StyledBiSave = styled(BiSave)` + height: 100%; + width: 20px; +`; + +const LoadProfileButton = styled.button` + padding: 0.5rem 0.5rem; + width: 100%; + color: ${({ theme }) => theme.blueCharcoal}; + background-color: ${({ theme }) => theme.pureWhite}; + border: none; + text-align: left; + font-size: 16px; + height: 32px; + + &:hover { + color: ${({ theme }) => theme.darkGrayBlue}; + background-color: ${({ theme }) => theme.aliceBlue1}; + } +`; + +const StyledForm = styled.form` + display: flex; + gap: 10px; +`; + +const StatefulIconButton = ({ + condition, + truthyIcon, + falsyIcon, + ...rest +}: StatefulIconButtonProps) => ( + + {condition ? truthyIcon : falsyIcon} + +); + +const EditableProfileLoadButton = ({ + name, + loadProfile, + onSubmit +}: EditableProfileLoadButtonProps) => { + const [isEditing, setIsEditing] = useState(false); + const { + register, + handleSubmit, + formState: { errors, isValid } + } = useForm<{ name: string }>({ + defaultValues: { + name: name + } + }); + + return ( + + {isEditing ? ( + + ) : ( + {name} + )} + } + falsyIcon={} + aria-label={`${isEditing ? 'Save' : 'Edit'} ${name}'s name`} + type="submit" + data-is-valid={isValid} + onClick={() => isValid && setIsEditing((prevState) => !prevState)} + /> + + ); +}; + +export const ControlProfiles = ({ id }: ControlProfilesProps) => { + const [virtualControlProfiles, setVirtualControlProfiles] = useLocalStorage< + VirtualControlProfiles | undefined + >(virtualControlProfilesLocalStorageKey); + const { layouts, setLayouts } = useLayoutContext(); + + const addProfile = () => { + setVirtualControlProfiles((prevState) => [ + ...(prevState ?? []), + { + id: nanoid(), + name: `Profile-${(prevState?.length ?? 0) + 1}`, + layouts: layouts, + active: true + } + ]); + }; + + const updateProfile = (id: string, updatedName: string) => { + setVirtualControlProfiles((prevState) => + prevState?.map((profile) => { + if (profile.id == id) + return { + ...profile, + name: updatedName + }; + + return profile; + }) + ); + }; + + const deleteProfile = (id: string) => { + setVirtualControlProfiles((prevState) => + prevState?.filter((p) => p.id !== id) + ); + }; + + return ( + <> + + {virtualControlProfiles?.map?.( + (profile: VirtualControlProfile, idx: number) => ( + + setLayouts(profile.layouts)} + onSubmit={({ name }) => updateProfile(profile.id, name)} + /> + deleteProfile(profile.id)} + > + + + + ) + )} + {!virtualControlProfiles?.length && ( +
  • + No control profiles +
  • + )} +
    + addProfile()} + > + + + + ); +}; diff --git a/gbajs3/src/components/modals/save-states.tsx b/gbajs3/src/components/modals/save-states.tsx index 4eb3f9c6..091abfc0 100644 --- a/gbajs3/src/components/modals/save-states.tsx +++ b/gbajs3/src/components/modals/save-states.tsx @@ -2,7 +2,7 @@ import { Button, IconButton, TextField } from '@mui/material'; import { useLocalStorage } from '@uidotdev/usehooks'; import { useCallback, useEffect, useId, useState } from 'react'; import { useForm, type SubmitHandler } from 'react-hook-form'; -import { BiError, BiPlus, BiTrash } from 'react-icons/bi'; +import { BiError, BiTrash } from 'react-icons/bi'; import { styled, useTheme } from 'styled-components'; import { ModalBody } from './modal-body.tsx'; @@ -16,7 +16,7 @@ import { } from '../product-tour/embedded-product-tour.tsx'; import { CircleCheckButton } from '../shared/circle-check-button.tsx'; import { ErrorWithIcon } from '../shared/error-with-icon.tsx'; -import { CenteredText } from '../shared/styled.tsx'; +import { CenteredText, StyledBiPlus } from '../shared/styled.tsx'; type InputProps = { saveStateSlot: number; @@ -69,11 +69,6 @@ const SaveStatesList = styled.ul` } `; -const StyledBiPlus = styled(BiPlus)` - width: 25px; - height: 25px; -`; - const StyledCiCircleRemove = styled(BiTrash)` height: 100%; width: 20px; diff --git a/gbajs3/src/components/shared/styled.tsx b/gbajs3/src/components/shared/styled.tsx index c68e3797..d23ef9a4 100644 --- a/gbajs3/src/components/shared/styled.tsx +++ b/gbajs3/src/components/shared/styled.tsx @@ -1,3 +1,4 @@ +import { BiPlus } from 'react-icons/bi'; import { styled } from 'styled-components'; export const CenteredTextContainer = styled.div` @@ -35,3 +36,8 @@ export const FooterWrapper = styled.div` border-top: 1px solid ${({ theme }) => theme.pattensBlue}; padding: 1rem 1rem; `; + +export const StyledBiPlus = styled(BiPlus)` + width: 25px; + height: 25px; +`; diff --git a/gbajs3/src/context/layout/layout.tsx b/gbajs3/src/context/layout/layout.tsx index f9803602..e57da53d 100644 --- a/gbajs3/src/context/layout/layout.tsx +++ b/gbajs3/src/context/layout/layout.tsx @@ -2,13 +2,13 @@ import { createContext, useCallback, useEffect, type ReactNode } from 'react'; import { useLayouts } from '../../hooks/use-layouts.tsx'; -type Layout = { +export type Layout = { position?: { x: number; y: number }; size?: { width: string | number; height: string | number }; initialBounds?: DOMRect; }; -type Layouts = { +export type Layouts = { [key: string]: Layout; }; @@ -17,6 +17,7 @@ type LayoutContextProps = { hasSetLayout: boolean; clearLayouts: () => void; setLayout: (layoutKey: string, layout: Layout) => void; + setLayouts: (layouts: Layouts) => void; }; type LayoutProviderProps = { children: ReactNode }; @@ -53,7 +54,8 @@ export const LayoutProvider = ({ children }: LayoutProviderProps) => { layouts, hasSetLayout, clearLayouts, - setLayout + setLayout, + setLayouts }} > {children} diff --git a/gbajs3/src/hooks/use-layouts.tsx b/gbajs3/src/hooks/use-layouts.tsx index 2ea627cd..ef43afa6 100644 --- a/gbajs3/src/hooks/use-layouts.tsx +++ b/gbajs3/src/hooks/use-layouts.tsx @@ -1,15 +1,7 @@ import { useLocalStorage } from '@uidotdev/usehooks'; import { useCallback, useMemo } from 'react'; -type Layout = { - position?: { x: number; y: number }; - size?: { width: string | number; height: string | number }; - initialBounds?: DOMRect; -}; - -type Layouts = { - [key: string]: Layout; -}; +import type { Layouts } from '../context/layout/layout'; const layoutLocalStorageKey = 'componentLayouts';