diff --git a/packages/components/.storybook/decorators/index.ts b/packages/components/.storybook/decorators/index.ts index 63bba383..64ed7803 100644 --- a/packages/components/.storybook/decorators/index.ts +++ b/packages/components/.storybook/decorators/index.ts @@ -1,2 +1,3 @@ export { default as withTokenSetup } from './withTokenSetup' export { default as withProviders } from './withProviders' +export { default as withMutationResolver } from './withMutationResolver' diff --git a/packages/components/.storybook/decorators/withMutationResolver.tsx b/packages/components/.storybook/decorators/withMutationResolver.tsx new file mode 100644 index 00000000..7eee5564 --- /dev/null +++ b/packages/components/.storybook/decorators/withMutationResolver.tsx @@ -0,0 +1,67 @@ +import React, { useEffect } from 'react' + +import { createTestEnvironment } from '@baseapp-frontend/graphql' + +import type { StoryContext, StoryFn } from '@storybook/react' +import { Observable, OperationDescriptor } from 'relay-runtime' +import { MockPayloadGenerator } from 'relay-test-utils' +import { MockResolvers } from 'relay-test-utils/lib/RelayMockPayloadGenerator' + +export type DynamicMockResolvers = (operation: OperationDescriptor) => MockResolvers + +const withMutationResolver = (Story: StoryFn, context: StoryContext) => { + const { environment, queueOperationResolver } = context.parameters + .relayMockEnvironment as ReturnType + + const mockResolvers = context.parameters.mockResolvers || undefined + const dynamicMockResolvers: DynamicMockResolvers = + context.parameters.dynamicMockResolvers || undefined + const mutationError: string = context.parameters.mutationError || undefined + + useEffect(() => { + const originalExecuteMutation = environment.executeMutation + + environment.executeMutation = (request) => { + if (!mutationError) { + if (dynamicMockResolvers) { + environment.mock.queueOperationResolver(() => { + return MockPayloadGenerator.generate( + request.operation, + dynamicMockResolvers(request.operation), + ) + }) + } else if (mockResolvers) { + environment.mock.queueOperationResolver(() => { + return MockPayloadGenerator.generate(request.operation, mockResolvers) + }) + } + } + + const observable = originalExecuteMutation.call(environment, request) + + return Observable.create((sink) => { + if (mutationError) { + setTimeout(() => { + sink.error(new Error(mutationError)) + }, 100) + } else { + observable.subscribe({ + complete: () => { + setTimeout(() => { + sink.complete() + }, 100) + }, + }) + } + }) + } + + return () => { + environment.executeMutation = originalExecuteMutation + } + }, [environment, queueOperationResolver]) + + return +} + +export default withMutationResolver diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 19a78142..baf59c67 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,13 @@ # @baseapp-frontend/components +## 1.0.18 + +### Patch Changes + +- Moved ProfileSettingsComponent and related queries and mutations from `baseapp-frontend-template` +- Updated dependencies + - @baseapp-frontend/design-system@1.0.7 + ## 1.0.17 ### Patch Changes diff --git a/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx b/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx index 85925c68..50ced407 100644 --- a/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx +++ b/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx @@ -32,7 +32,7 @@ const ChatRoomListItem: FC = ({ profile: profileRef, onCh {name} - {urlPath?.path && `@${urlPath.path}`} + {urlPath?.path && `@${urlPath.path?.replace('/', '')}`} { +export const getProfileDefaultValues = ({ + profile, + removeSlashInUsername = true, +}: ProfileGetDefaultFormValues) => { const formattedProfile = { ...profile, phoneNumber: profile?.owner?.phoneNumber ?? DEFAULT_PHONE_NUMBER_COUNTRY_CODE, @@ -15,6 +18,9 @@ export const getDefaultValues = (profile?: ProfileComponentFragment$data | null) urlPath: profile?.urlPath?.path ?? '', biography: profile?.biography ?? '', } + if (removeSlashInUsername) { + formattedProfile.urlPath = formattedProfile.urlPath?.replace('/', '') + } const defaultValues = getInitialValues({ current: formattedProfile, initial: DEFAULT_PROFILE_FORM_VALUES, diff --git a/packages/components/modules/profiles/common/zod.ts b/packages/components/modules/profiles/common/zod.ts index 478b25e2..f0e8a7b6 100644 --- a/packages/components/modules/profiles/common/zod.ts +++ b/packages/components/modules/profiles/common/zod.ts @@ -15,4 +15,8 @@ export const PROFILE_FORM_VALIDATION = { empty: 'Please enter a phone number.', invalid: 'Invalid phone number.', }, + urlPath: { + empty: 'Username must be at least 8 characters long.', + invalid: 'Username can only contain letters and numbers', + }, } diff --git a/packages/components/modules/profiles/native/ProfileComponent/index.tsx b/packages/components/modules/profiles/native/ProfileComponent/index.tsx index 3d1b0f2c..3b745f11 100644 --- a/packages/components/modules/profiles/native/ProfileComponent/index.tsx +++ b/packages/components/modules/profiles/native/ProfileComponent/index.tsx @@ -33,7 +33,7 @@ const ProfileComponent = ({ profile: profileRef }: ProfileComponentProps) => { {profile?.name} - @{profile?.urlPath?.path} + @{profile?.urlPath?.path?.replace('/', '')} diff --git a/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx b/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx index 7068ed4e..9efe50e1 100644 --- a/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx +++ b/packages/components/modules/profiles/native/ProfileSettingsComponent/index.tsx @@ -27,8 +27,8 @@ import { PROFILE_FORM_VALUE, ProfileComponentFragment, ProfileUpdateForm, - getDefaultValues, getImageUrl, + getProfileDefaultValues, useProfileMutation, } from '../../common' import BottomDrawer from './BottomDrawer' @@ -46,7 +46,7 @@ const ProfileSettingsComponent: FC = ({ profile: const { sendToast } = useNotification() const formReturn = useForm({ - defaultValues: getDefaultValues(profile), + defaultValues: getProfileDefaultValues({ profile }), resolver: zodResolver(DEFAULT_PROFILE_FORM_VALIDATION), mode: 'onBlur', }) diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/index.tsx b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/index.tsx new file mode 100644 index 00000000..b53c74ed --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/index.tsx @@ -0,0 +1,14 @@ +import { useLazyLoadQuery } from 'react-relay' + +import { ProfileSettingsRelayTestQuery as QueryType } from '../../../../../__generated__/ProfileSettingsRelayTestQuery.graphql' +import { ProfileSettingsRelayTest } from '../../../common/graphql/queries/ProfileSettingsRelayTest' +import ProfileSettingsComponent from '../index' +import { ProfileSettingsComponentProps } from '../types' + +const ProfileSettingsComponentWithQuery = (props: ProfileSettingsComponentProps) => { + const profileRef = useLazyLoadQuery(ProfileSettingsRelayTest, {}) + + return +} + +export default ProfileSettingsComponentWithQuery diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/mockResolver.ts b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/mockResolver.ts new file mode 100644 index 00000000..c8efba3c --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/mockResolver.ts @@ -0,0 +1,30 @@ +export const mockResolvers = { + Node: () => ({ + __typename: 'Profile', + id: '1', + name: 'Profile 1', + biography: 'Profile 1 biography', + image: null, + bannerImage: null, + canChange: true, + canDelete: true, + urlPath: { + path: '/profile/1', + }, + owner: { + phoneNumber: '1234567890', + }, + }), + Mutation: () => ({ + profileUpdate: { + errors: null, + }, + }), +} + +export const mockResolversWithMutationError = { + ...mockResolvers, + Mutation: () => ({ + profileUpdate: {}, + }), +} diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/stories.tsx b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/stories.tsx new file mode 100644 index 00000000..78614bb7 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/__storybook__/stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react' + +import { withMutationResolver } from '../../../../../.storybook/decorators' +import { ProfileSettingsComponentProps } from '../types' +import ProfileSettingsComponentWithQuery from './index' +import { mockResolvers, mockResolversWithMutationError } from './mockResolver' + +export default { + title: '@baseapp-frontend-template / Pages/User/Settings/AccountSettings/AccountProfile', + component: ProfileSettingsComponentWithQuery, + decorators: [withMutationResolver], + args: { + profile: {}, + }, + argTypes: { + profile: { + description: 'Graphql reference to extract the Profile fragment.', + table: { + type: { + summary: 'AccountProfileFragment$key | null', + }, + }, + control: false, + }, + }, + parameters: { + layout: 'centered', + }, +} as Meta + +const Template: StoryFn = (args) => + +type Story = StoryObj + +export const Default: Story = Template.bind({}) +Default.storyName = 'AccountProfile' +Default.parameters = { + mockResolvers, +} + +export const ErrorStatus: Story = Template.bind({}) +ErrorStatus.storyName = 'AccountProfile with Error' +ErrorStatus.parameters = { + mockResolvers: mockResolversWithMutationError, +} diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx b/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx new file mode 100644 index 00000000..ace30a9c --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/index.tsx @@ -0,0 +1,278 @@ +'use client' + +import { FC, useEffect } from 'react' + +import { useCurrentProfile } from '@baseapp-frontend/authentication' +import { CircledAvatar } from '@baseapp-frontend/design-system/components/web/avatars' +import { FileUploadButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { UsernameIcon } from '@baseapp-frontend/design-system/components/web/icons' +import { ImageWithFallback } from '@baseapp-frontend/design-system/components/web/images' +import { + PhoneNumberField, + TextField, + TextareaField, +} from '@baseapp-frontend/design-system/components/web/inputs' +import { filterDirtyValues, setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' + +import { zodResolver } from '@hookform/resolvers/zod' +import LoadingButton from '@mui/lab/LoadingButton' +import { Card, CardContent, InputAdornment, Typography } from '@mui/material' +import { useForm } from 'react-hook-form' +import { useFragment } from 'react-relay' + +import { + DEFAULT_BANNER_IMAGE_FORMATS, + DEFAULT_BANNER_IMAGE_MAX_SIZE, + DEFAULT_IMAGE_FORMATS, + DEFAULT_IMAGE_MAX_SIZE, + DEFAULT_PROFILE_FORM_VALIDATION, + PROFILE_FORM_VALUE, + ProfileComponentFragment, + ProfileUpdateForm, + UploadablesObj, + getImageUrl, + getProfileDefaultValues, + useProfileMutation, +} from '../../common' +import { BannerButtonsContainer } from './styled' +import { ProfileSettingsComponentProps } from './types' + +const ProfileSettingsComponent: FC = ({ profile: profileRef }) => { + const profile = useFragment(ProfileComponentFragment, profileRef) + + const { sendToast } = useNotification() + const { updateProfileIfActive } = useCurrentProfile() + + const formReturn = useForm({ + defaultValues: getProfileDefaultValues({ profile, removeSlashInUsername: true }), + resolver: zodResolver(DEFAULT_PROFILE_FORM_VALIDATION), + mode: 'onBlur', + }) + + const { + clearErrors, + control, + getFieldState, + handleSubmit, + reset, + setValue, + watch, + formState: { isDirty, dirtyFields, isValid }, + } = formReturn + + const [commitMutation, isMutationInFlight] = useProfileMutation() + + const watchImage = watch(PROFILE_FORM_VALUE.image) + const watchBannerImage = watch(PROFILE_FORM_VALUE.bannerImage) + const imageUrl = getImageUrl(watchImage) + const bannerImageUrl = getImageUrl(watchBannerImage) + + // To get this working in staging/prod, change NEXT_PUBLIC_REMOTE_PATTERNS_HOSTNAME to the media host being used (e.g.: digitalocean, aws, etc) + const hasUploadedImage = imageUrl.includes(process.env.NEXT_PUBLIC_REMOTE_PATTERNS_HOSTNAME ?? '') + const hasUploadedBannerImage = bannerImageUrl.includes( + process.env.NEXT_PUBLIC_REMOTE_PATTERNS_HOSTNAME ?? '', + ) + + const onSubmit = async (data: ProfileUpdateForm) => { + const dirtyValues = filterDirtyValues({ values: data, dirtyFields }) + const { id, image, bannerImage } = data + const uploadables: UploadablesObj = {} + if ('image' in dirtyValues && image && typeof image !== 'string') { + uploadables.image = image + delete dirtyValues.image + } + if ('bannerImage' in dirtyValues && bannerImage && typeof bannerImage !== 'string') { + uploadables.bannerImage = bannerImage + delete dirtyValues.bannerImage + } + + commitMutation({ + variables: { + input: { id, ...dirtyValues }, + }, + uploadables, + onCompleted: (response: any) => { + const errors = response?.profileUpdate?.errors + if (errors) { + sendToast('Something went wrong', { type: 'error' }) + setFormRelayErrors(formReturn, errors) + } else { + sendToast('Profile updated', { type: 'success' }) + } + }, + }) + reset({}, { keepValues: true }) + } + + useEffect(() => { + if (profile) { + updateProfileIfActive({ + id: profile.id, + name: profile.name ?? null, + urlPath: profile.urlPath?.path ?? null, + image: profile.image?.url ?? null, + }) + } + }, [profile?.id, profile?.name, profile?.urlPath?.path, profile?.image?.url]) + + const handleRemoveImage = (type: any) => { + clearErrors(type) + setValue(type, null, { + shouldValidate: false, + shouldDirty: true, + shouldTouch: true, + }) + } + + return ( + + +
+
+ + Profile + + + Manage your personal information you and other people see. + +
+
+ + +
+ + {getFieldState('image').error && ( +
+ + {getFieldState('image').error!.message} + +
+ )} + + {watchImage && ( + handleRemoveImage(PROFILE_FORM_VALUE.image)} + > + Remove + + )} +
+
+
+
+ + + + + ), + }} + /> + +
+
+ + + +
+ + {getFieldState('bannerImage').error && ( +
+ + {getFieldState('bannerImage').error!.message} + +
+ )} + + + {watchBannerImage && ( + handleRemoveImage(PROFILE_FORM_VALUE.bannerImage)} + loading={isMutationInFlight} + disabled={isMutationInFlight} + sx={{ maxWidth: 'fit-content' }} + > + Remove + + )} + +
+
+
+
+ + Save Changes + +
+ +
+
+ ) +} + +export default ProfileSettingsComponent diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/styled.tsx b/packages/components/modules/profiles/web/ProfileSettingsComponent/styled.tsx new file mode 100644 index 00000000..4775ee90 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/styled.tsx @@ -0,0 +1,11 @@ +import { styled } from '@mui/material/styles' + +export const BannerButtonsContainer = styled('div')<{ enableRemove: boolean }>( + ({ theme, enableRemove = false }) => ({ + display: 'grid', + gridTemplateColumns: enableRemove ? '1fr 1fr' : '1fr', + gridTemplateRows: '1fr', + gap: theme.spacing(2), + placeSelf: enableRemove ? 'inherit' : 'center', + }), +) diff --git a/packages/components/modules/profiles/web/ProfileSettingsComponent/types.ts b/packages/components/modules/profiles/web/ProfileSettingsComponent/types.ts new file mode 100644 index 00000000..1c87f9c6 --- /dev/null +++ b/packages/components/modules/profiles/web/ProfileSettingsComponent/types.ts @@ -0,0 +1,5 @@ +import { ProfileComponentFragment$key } from '../../../../__generated__/ProfileComponentFragment.graphql' + +export interface ProfileSettingsComponentProps { + profile?: ProfileComponentFragment$key | null +} diff --git a/packages/components/modules/profiles/web/index.ts b/packages/components/modules/profiles/web/index.ts index d0c9af0a..bd67479e 100644 --- a/packages/components/modules/profiles/web/index.ts +++ b/packages/components/modules/profiles/web/index.ts @@ -16,3 +16,6 @@ export type * from './ProfileMembers/types' export { default as ProfileComponent } from './ProfileComponent' export type * from './ProfileComponent/types' + +export { default as ProfileSettingsComponent } from './ProfileSettingsComponent' +export type * from './ProfileSettingsComponent/types' diff --git a/packages/components/modules/profiles/web/profile-popover/CurrentProfile/index.tsx b/packages/components/modules/profiles/web/profile-popover/CurrentProfile/index.tsx index 883bf752..04b3b86d 100644 --- a/packages/components/modules/profiles/web/profile-popover/CurrentProfile/index.tsx +++ b/packages/components/modules/profiles/web/profile-popover/CurrentProfile/index.tsx @@ -26,7 +26,7 @@ const CurrentProfile: FC = () => { {profile?.urlPath && ( - {profile.urlPath} + {profile?.urlPath?.replace('/', '')} )} diff --git a/packages/components/package.json b/packages/components/package.json index 25492e13..40af8286 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/components", "description": "BaseApp components modules such as comments, notifications, messages, and more.", - "version": "1.0.17", + "version": "1.0.18", "sideEffects": false, "scripts": { "babel:transpile": "babel modules -d tmp-babel --extensions .ts,.tsx --ignore '**/__tests__/**','**/__storybook__/**'", diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index f2bfc098..5d850118 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -1,5 +1,11 @@ # @baseapp-frontend/design-system +## 1.0.7 + +### Patch Changes + +- Moved PhoneNumberInput and UsernameIcon from `baseapp-frontend-template` + ## 1.0.6 ### Patch Changes diff --git a/packages/design-system/components/web/icons/UsernameIcon/index.tsx b/packages/design-system/components/web/icons/UsernameIcon/index.tsx new file mode 100644 index 00000000..255f359b --- /dev/null +++ b/packages/design-system/components/web/icons/UsernameIcon/index.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react' + +import { SvgIcon, SvgIconProps } from '@mui/material' + +const UsernameIcon: FC = ({ sx, ...props }) => ( + + + + + +) + +export default UsernameIcon diff --git a/packages/design-system/components/web/icons/index.ts b/packages/design-system/components/web/icons/index.ts index 6b66dfa1..f0a7b07c 100644 --- a/packages/design-system/components/web/icons/index.ts +++ b/packages/design-system/components/web/icons/index.ts @@ -31,3 +31,4 @@ export { default as TrashCanIcon } from './TrashCanIcon' export { default as UnarchiveIcon } from './UnarchiveIcon' export { default as UnblockIcon } from './UnblockIcon' export { default as UnreadIcon } from './UnreadIcon' +export { default as UsernameIcon } from './UsernameIcon' diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/index.tsx b/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/index.tsx new file mode 100644 index 00000000..b1b64cb1 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/index.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react' + +import { MenuItem, Typography } from '@mui/material' +import { + CountryData, + CountryIso2, + FlagImage, + defaultCountries, + parseCountry, +} from 'react-international-phone' + +import { CountrySelectProps } from '../types' +import { CountryTitle, Select as StyledSelect } from './styled' + +export const CountrySelect: FC = ({ + country, + setCountry, + selectProps, + countryNameProps, + countryDialCodeProps, + optionProps, +}) => ( + setCountry(event.target.value as CountryIso2)} + renderValue={(value) => } + > + {defaultCountries.map((c: CountryData) => { + const defaultCountry = parseCountry(c) + return ( + + + {defaultCountry.name} + +{defaultCountry.dialCode} + + ) + })} + +) diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/styled.tsx b/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/styled.tsx new file mode 100644 index 00000000..6774962a --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/CountrySelect/styled.tsx @@ -0,0 +1,30 @@ +import { Select as MUISelect, SelectProps, Typography, TypographyProps } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const Select = styled((props) => )(({ theme }) => ({ + width: 'max-content', + fieldset: { + display: 'none', + }, + '&.Mui-focused:has(div[aria-expanded="false"])': { + fieldset: { + display: 'block', + }, + }, + '.MuiSelect-select': { + padding: theme.spacing(1), + paddingRight: theme.spacing(3), + [theme.breakpoints.down('sm')]: { + padding: theme.spacing(1 / 4, 1), + }, + }, + svg: { + right: 0, + }, +})) + +export const CountryTitle = styled((props) => )( + ({ theme }) => ({ + margin: theme.spacing(0, 1), + }), +) diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/PhoneNumberField.mdx b/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/PhoneNumberField.mdx new file mode 100644 index 00000000..46af2782 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/PhoneNumberField.mdx @@ -0,0 +1,42 @@ +import { Meta } from '@storybook/addon-docs' + + + +# Component Documentation + +## PhoneNumberField + +- **Purpose**: The `PhoneNumberField` component provides a text field that supports phone number formatting and validation. It enhances user experience by allowing users to select their country and enter a phone number in the correct format. +- **Expected Behavior**: The component should display a text field with a country select dropdown. The phone number input should be formatted according to the selected country. It should handle various states such as loading, loaded, and error. + +## Use Cases + +- **Current Usage**: This component is currently used in forms where users need to enter their phone numbers, such as registration or profile update forms. +- **Potential Usage**: The `PhoneNumberField` could be used in any scenario where phone number input is required, such as contact forms, booking systems, or customer support interfaces. + +## Props + +- **value** (string, optional): The current value of the phone number input. +- **onChange** (function, optional): Callback function to handle changes to the phone number input. +- **defaultCountry** (string, optional): The default country code for the phone number input. +- **optionProps** (object, optional): Additional props for the country select options. +- **selectProps** (object, optional): Additional props for the country select dropdown. +- **countryNameProps** (object, optional): Additional props for the country name display. +- **countryDialCodeProps** (object, optional): Additional props for the country dial code display. + +## Notes + +- **Related Components**: + - `PureTextField`: Used to create the base input field with additional functionality. + - `CountrySelect`: Used to display the country select dropdown. + +## Example Usage + +```javascript +import PhoneNumberField from '../PhoneNumberField' + +const MyComponent = () => ( + console.log(phone)} defaultCountry="US" /> +) +export default MyComponent +``` diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/stories.tsx b/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/stories.tsx new file mode 100644 index 00000000..5474e486 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/__storybook__/stories.tsx @@ -0,0 +1,22 @@ +import { WithControllerProps } from '@baseapp-frontend/utils' + +import { Meta, StoryObj } from '@storybook/react' + +import PhoneNumberField from '..' +import { PhoneNumberFieldProps } from '../types' + +const meta: Meta> = { + title: '@baseapp-frontend | designSystem/Inputs/PhoneNumberField', + component: PhoneNumberField, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Phone Number', + placeholder: 'Enter your phone number', + defaultCountry: 'US', + }, +} diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/constants.ts b/packages/design-system/components/web/inputs/PhoneNumberField/constants.ts new file mode 100644 index 00000000..150cec69 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_PHONE_NUMBER_COUNTRY_CODE = '+1' diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/index.tsx b/packages/design-system/components/web/inputs/PhoneNumberField/index.tsx new file mode 100644 index 00000000..2c91ab8f --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/index.tsx @@ -0,0 +1,69 @@ +'use client' + +import { FC } from 'react' + +import { withController } from '@baseapp-frontend/utils' + +import { defaultCountries, usePhoneInput } from 'react-international-phone' +import 'react-international-phone/style.css' + +import { PureTextField } from '../TextField' +import { CountrySelect } from './CountrySelect' +import { InputAdornment } from './styled' +import { PhoneNumberFieldProps } from './types' + +/** + * This is a TextField component that supports phone number formatting and validation. + * + * @description + * This is a **BaseApp** feature. + * + * Developers can freely edit this to suit the project's needs. + * + * If you believe your changes should be in the BaseApp, please read the **CONTRIBUTING.md** guide. + */ +const PhoneNumberField: FC = ({ + value, + onChange, + defaultCountry, + optionProps, + selectProps, + countryNameProps, + countryDialCodeProps, + ...props +}) => { + const { inputValue, handlePhoneValueChange, inputRef, country, setCountry } = usePhoneInput({ + defaultCountry, + value, + countries: defaultCountries, + onChange: (data: any) => { + onChange?.(data.phone) + }, + }) + + return ( + + + + ), + }} + {...props} + /> + ) +} + +export default withController(PhoneNumberField) diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/styled.tsx b/packages/design-system/components/web/inputs/PhoneNumberField/styled.tsx new file mode 100644 index 00000000..653a4796 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/styled.tsx @@ -0,0 +1,9 @@ +import { InputAdornmentProps, InputAdornment as MUIInputAdornment } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const InputAdornment = styled((props: InputAdornmentProps) => ( + +))(({ theme }) => ({ + marginRight: theme.spacing(1 / 4), + marginLeft: `-${theme.spacing(1)}`, +})) diff --git a/packages/design-system/components/web/inputs/PhoneNumberField/types.ts b/packages/design-system/components/web/inputs/PhoneNumberField/types.ts new file mode 100644 index 00000000..af41e4a5 --- /dev/null +++ b/packages/design-system/components/web/inputs/PhoneNumberField/types.ts @@ -0,0 +1,21 @@ +import type { MenuItemProps, SelectProps, TypographyProps } from '@mui/material' +import type { CountryIso2, ParsedCountry } from 'react-international-phone' + +import { TextFieldProps } from '../TextField/types' + +export interface CountrySelectProps { + country: ParsedCountry + setCountry: (iso2: CountryIso2) => void + selectProps?: SelectProps + countryNameProps?: TypographyProps + countryDialCodeProps?: TypographyProps + optionProps?: MenuItemProps +} + +export interface PhoneNumberProps extends Omit { + value?: string + onChange?: (phone: string) => void + defaultCountry?: CountryIso2 +} + +export type PhoneNumberFieldProps = TextFieldProps & PhoneNumberProps diff --git a/packages/design-system/components/web/inputs/index.ts b/packages/design-system/components/web/inputs/index.ts index d45d9d44..d7cfc9d7 100644 --- a/packages/design-system/components/web/inputs/index.ts +++ b/packages/design-system/components/web/inputs/index.ts @@ -1,10 +1,13 @@ 'use client' +export { default as PhoneNumberField } from './PhoneNumberField' +export type * from './PhoneNumberField/types' export { default as Searchbar } from './Searchbar' export type * from './Searchbar/types' export { default as SocialTextField } from './SocialTextField' export type * from './SocialTextField/types' export { default as TextareaField } from './TextareaField' +export type * from './TextareaField/types' export { default as TextField } from './TextField' export * from './TextField' export type * from './TextField/types' diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 49bdb0f8..87b92a8f 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/design-system", "description": "Design System components and configurations.", - "version": "1.0.6", + "version": "1.0.7", "sideEffects": false, "scripts": { "tsup:bundle": "tsup --tsconfig tsconfig.build.json", @@ -78,6 +78,7 @@ "next": "catalog:", "react-dropzone": "^14.2.3", "react-hook-form": "catalog:", + "react-international-phone": "^4.5.0", "react-lazy-load-image-component": "^1.6.2", "react-native": "catalog:react-native-core", "react-native-gesture-handler": "catalog:react-native-core", diff --git a/packages/wagtail/CHANGELOG.md b/packages/wagtail/CHANGELOG.md index eeeabd45..da33eac4 100644 --- a/packages/wagtail/CHANGELOG.md +++ b/packages/wagtail/CHANGELOG.md @@ -1,5 +1,12 @@ # @baseapp-frontend/wagtail +## 1.0.24 + +### Patch Changes + +- Updated dependencies + - @baseapp-frontend/design-system@1.0.7 + ## 1.0.23 ### Patch Changes diff --git a/packages/wagtail/package.json b/packages/wagtail/package.json index 66c9fc42..84e69173 100644 --- a/packages/wagtail/package.json +++ b/packages/wagtail/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/wagtail", "description": "BaseApp Wagtail", - "version": "1.0.23", + "version": "1.0.24", "main": "./index.ts", "types": "dist/index.d.ts", "sideEffects": false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d61db5ff..c6f29795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -977,6 +977,9 @@ importers: react-hook-form: specifier: 'catalog:' version: 7.51.5(react@18.3.1) + react-international-phone: + specifier: ^4.5.0 + version: 4.5.0(react@18.3.1) react-lazy-load-image-component: specifier: ^1.6.2 version: 1.6.3(react@18.3.1) @@ -9360,6 +9363,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 + react-international-phone@4.5.0: + resolution: {integrity: sha512-wjwHv+VfiwM49B5/6El4Z5vZKmf3ILpUeiOCI9X+b0Dq4g5nL8gROcwCdVcTXywxznbDSoxSassBX3i9tPZX6g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -21150,6 +21158,10 @@ snapshots: dependencies: react: 18.3.1 + react-international-phone@4.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {}