diff --git a/ui/dashboard/public/locales/en/common.json b/ui/dashboard/public/locales/en/common.json index cc1f0e40f2..fffdfffece 100644 --- a/ui/dashboard/public/locales/en/common.json +++ b/ui/dashboard/public/locales/en/common.json @@ -137,5 +137,15 @@ "edit-push": "Update Push Notification", "new-push-subtitle": "Every time a feature flag is updated, a push notification is sent to the SDK client allowing you to update the SDK cache in real-time.", "tags": "Tags", - "fcm-api-key": "Firebase Service Account" + "fcm-api-key": "Firebase Service Account", + "user-segments": "User Segments", + "user-segments-subtitle": "You can create segments to help you start testing feature flags for specifics type of users", + "new-user-segment": "New User Segment", + "update-user-segment": "Update User Segment", + "delete-user-segment": "Delete User Segment", + "connections": "Connections", + "status": "Status", + "flags-connected": "Flags Connected", + "flags-connected-desc": "This user segment is currently connected to the following feature flags:", + "close": "Close" } diff --git a/ui/dashboard/public/locales/en/form.json b/ui/dashboard/public/locales/en/form.json index 1b1335d5f9..d384add8cb 100644 --- a/ui/dashboard/public/locales/en/form.json +++ b/ui/dashboard/public/locales/en/form.json @@ -46,6 +46,13 @@ "account": "Get notified when someone adds or updates an account", "notification": "Get notified when someone adds or updates a notification" }, - "upload-files": "Drop your file here, or {{text}}", + "placeholder-search-user-segments": "Search user segment", + "list-of-users-ids": "List Of Users IDs", + "browse-files": "Browse files", + "enter-user-ids": "Enter user IDs", + "upload-files": "Drop your files here, or {{text}}", + "update-user-segment": "Updating this field will replace the current list with the new one.", + "update-user-segment-warning": "

The user ID list can't be updated because {{count}} flag is using it. Remove the segment from the flag before updating it.

", + "placeholder-enter-user-ids": "Enter IDs separated by commas (E.g., userId1, userId2, userId3,...)", "accept-file-types": "{{type}}" } diff --git a/ui/dashboard/public/locales/en/table.json b/ui/dashboard/public/locales/en/table.json index 685b1dc9bf..490c129da7 100644 --- a/ui/dashboard/public/locales/en/table.json +++ b/ui/dashboard/public/locales/en/table.json @@ -1,6 +1,7 @@ { "flags": "Flags", "created-at": "Created At", + "updated-at": "Updated At", "last-seen": "Last Seen", "popover": { "edit-project": "Edit Project", @@ -23,7 +24,10 @@ "enable-notification": "Enable Notification", "edit-push": "Edit Push", "disable-push": "Disable Push", - "enable-push": "Enable Push" + "enable-push": "Enable Push", + "edit-segment": "Edit User Segment", + "delete-segment": "Delete User Segment", + "download-segment": "Download User Segment" }, "empty": { "project-title": "No registered projects", @@ -40,7 +44,9 @@ "notification-title": "No registered notifications", "notification-desc": "There are no registered notifications. Add a new one to start managing.", "push-title": "No registered pushes", - "push-desc": "There are no registered pushes. Add a new one to start managing." + "push-desc": "There are no registered pushes. Add a new one to start managing.", + "user-segments-title": "No registered user segments", + "user-segments-desc": "There are no registered user segments. Add a new one to start managing." }, "organization": { "confirm-archive-desc": "The organization \"{{name}}\" will be archived. Are you sure you want to proceed?", @@ -66,5 +72,8 @@ "push": { "confirm-enable-desc": "The Push \"{{name}}\" will be enabled. Are you sure you want to proceed?", "confirm-disable-desc": "The Push \"{{name}}\" will be disabled. Are you sure you want to proceed?" + }, + "user-segment": { + "delete-user-segment-desc": "The {{name}} is going to be deleted permanently. Are you sure you want to proceed?" } } diff --git a/ui/dashboard/public/locales/ja/common.json b/ui/dashboard/public/locales/ja/common.json index cc1f0e40f2..fffdfffece 100644 --- a/ui/dashboard/public/locales/ja/common.json +++ b/ui/dashboard/public/locales/ja/common.json @@ -137,5 +137,15 @@ "edit-push": "Update Push Notification", "new-push-subtitle": "Every time a feature flag is updated, a push notification is sent to the SDK client allowing you to update the SDK cache in real-time.", "tags": "Tags", - "fcm-api-key": "Firebase Service Account" + "fcm-api-key": "Firebase Service Account", + "user-segments": "User Segments", + "user-segments-subtitle": "You can create segments to help you start testing feature flags for specifics type of users", + "new-user-segment": "New User Segment", + "update-user-segment": "Update User Segment", + "delete-user-segment": "Delete User Segment", + "connections": "Connections", + "status": "Status", + "flags-connected": "Flags Connected", + "flags-connected-desc": "This user segment is currently connected to the following feature flags:", + "close": "Close" } diff --git a/ui/dashboard/public/locales/ja/form.json b/ui/dashboard/public/locales/ja/form.json index 1b1335d5f9..d384add8cb 100644 --- a/ui/dashboard/public/locales/ja/form.json +++ b/ui/dashboard/public/locales/ja/form.json @@ -46,6 +46,13 @@ "account": "Get notified when someone adds or updates an account", "notification": "Get notified when someone adds or updates a notification" }, - "upload-files": "Drop your file here, or {{text}}", + "placeholder-search-user-segments": "Search user segment", + "list-of-users-ids": "List Of Users IDs", + "browse-files": "Browse files", + "enter-user-ids": "Enter user IDs", + "upload-files": "Drop your files here, or {{text}}", + "update-user-segment": "Updating this field will replace the current list with the new one.", + "update-user-segment-warning": "

The user ID list can't be updated because {{count}} flag is using it. Remove the segment from the flag before updating it.

", + "placeholder-enter-user-ids": "Enter IDs separated by commas (E.g., userId1, userId2, userId3,...)", "accept-file-types": "{{type}}" } diff --git a/ui/dashboard/public/locales/ja/table.json b/ui/dashboard/public/locales/ja/table.json index 685b1dc9bf..490c129da7 100644 --- a/ui/dashboard/public/locales/ja/table.json +++ b/ui/dashboard/public/locales/ja/table.json @@ -1,6 +1,7 @@ { "flags": "Flags", "created-at": "Created At", + "updated-at": "Updated At", "last-seen": "Last Seen", "popover": { "edit-project": "Edit Project", @@ -23,7 +24,10 @@ "enable-notification": "Enable Notification", "edit-push": "Edit Push", "disable-push": "Disable Push", - "enable-push": "Enable Push" + "enable-push": "Enable Push", + "edit-segment": "Edit User Segment", + "delete-segment": "Delete User Segment", + "download-segment": "Download User Segment" }, "empty": { "project-title": "No registered projects", @@ -40,7 +44,9 @@ "notification-title": "No registered notifications", "notification-desc": "There are no registered notifications. Add a new one to start managing.", "push-title": "No registered pushes", - "push-desc": "There are no registered pushes. Add a new one to start managing." + "push-desc": "There are no registered pushes. Add a new one to start managing.", + "user-segments-title": "No registered user segments", + "user-segments-desc": "There are no registered user segments. Add a new one to start managing." }, "organization": { "confirm-archive-desc": "The organization \"{{name}}\" will be archived. Are you sure you want to proceed?", @@ -66,5 +72,8 @@ "push": { "confirm-enable-desc": "The Push \"{{name}}\" will be enabled. Are you sure you want to proceed?", "confirm-disable-desc": "The Push \"{{name}}\" will be disabled. Are you sure you want to proceed?" + }, + "user-segment": { + "delete-user-segment-desc": "The {{name}} is going to be deleted permanently. Are you sure you want to proceed?" } } diff --git a/ui/dashboard/src/@api/user-segment/index.ts b/ui/dashboard/src/@api/user-segment/index.ts new file mode 100644 index 0000000000..ba6dc5eb0e --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/index.ts @@ -0,0 +1,5 @@ +export * from './user-segments-fetcher'; +export * from './user-segment-creator'; +export * from './user-segment-bulk-upload'; +export * from './user-segment-fetcher'; +export * from './user-segment-bulk-download'; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-bulk-download.ts b/ui/dashboard/src/@api/user-segment/user-segment-bulk-download.ts new file mode 100644 index 0000000000..171ce4f7cf --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-bulk-download.ts @@ -0,0 +1,23 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { isNotEmpty } from 'utils/data-type'; + +export interface UserSegmentBulkDownloadParams { + segmentId: string; + environmentId: string; + state?: 'INCLUDED' | 'EXCLUDED'; +} + +export interface UserSegmentBulkDownloadResponse { + data: string; +} + +export const userSegmentBulkDownload = async ( + params?: UserSegmentBulkDownloadParams +): Promise => { + return axiosClient + .get('/v1/segment_users/bulk_download', { + params: pickBy(params, v => isNotEmpty(v)) + }) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-bulk-upload.ts b/ui/dashboard/src/@api/user-segment/user-segment-bulk-upload.ts new file mode 100644 index 0000000000..4450bc03ff --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-bulk-upload.ts @@ -0,0 +1,16 @@ +import axiosClient from '@api/axios-client'; + +export interface UserSegmentBulkUploadParams { + segmentId: string; + environmentId?: string; + data?: string; + state?: 'INCLUDED' | 'EXCLUDED'; +} + +export const userSegmentBulkUpload = async ( + params?: UserSegmentBulkUploadParams +) => { + return axiosClient + .post('/v1/segment_users/bulk_upload', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-creator.ts b/ui/dashboard/src/@api/user-segment/user-segment-creator.ts new file mode 100644 index 0000000000..1a186e2e83 --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-creator.ts @@ -0,0 +1,20 @@ +import axiosClient from '@api/axios-client'; +import { UserSegment } from '@types'; + +export interface UserSegmentCreatorParams { + environmentId: string; + name: string; + description?: string; +} + +export interface UserSegmentCreatorResponse { + segment: UserSegment; +} + +export const userSegmentCreator = async ( + params?: UserSegmentCreatorParams +): Promise => { + return axiosClient + .post('/v1/segment', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-delete.ts b/ui/dashboard/src/@api/user-segment/user-segment-delete.ts new file mode 100644 index 0000000000..f7af0792fe --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-delete.ts @@ -0,0 +1,17 @@ +import axiosClient from '@api/axios-client'; +import { pickBy } from 'lodash'; +import { isNotEmpty } from 'utils/data-type'; +import { stringifyParams } from 'utils/search-params'; + +export interface UserSegmentDeleteParams { + id: string; + environmentId: string; +} + +export const userSegmentDelete = async (_params?: UserSegmentDeleteParams) => { + const params = pickBy(_params, v => isNotEmpty(v)); + + return axiosClient + .delete(`/v1/segment?${stringifyParams(params)}`) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-fetcher.ts b/ui/dashboard/src/@api/user-segment/user-segment-fetcher.ts new file mode 100644 index 0000000000..620da39011 --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-fetcher.ts @@ -0,0 +1,23 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { UserSegment } from '@types'; +import { isNotEmpty } from 'utils/data-type'; + +export interface UserSegmentFetcherParams { + id: string; + environmentId: string; +} + +export interface UserSegmentResponse { + segment: Array; +} + +export const userSegmentFetcher = async ( + params?: UserSegmentFetcherParams +): Promise => { + return axiosClient + .get('/v1/segment', { + params: pickBy(params, v => isNotEmpty(v)) + }) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segment-updater.ts b/ui/dashboard/src/@api/user-segment/user-segment-updater.ts new file mode 100644 index 0000000000..95a5bb7874 --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segment-updater.ts @@ -0,0 +1,21 @@ +import axiosClient from '@api/axios-client'; +import { UserSegment } from '@types'; + +export interface UserSegmentUpdaterParams { + id: string; + environmentId: string; + name?: string; + description?: string; +} + +export interface UserSegmentUpdaterResponse { + segment: UserSegment; +} + +export const userSegmentUpdater = async ( + params?: UserSegmentUpdaterParams +): Promise => { + return axiosClient + .patch('/v1/segment', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/user-segment/user-segments-fetcher.ts b/ui/dashboard/src/@api/user-segment/user-segments-fetcher.ts new file mode 100644 index 0000000000..0a7048cb2e --- /dev/null +++ b/ui/dashboard/src/@api/user-segment/user-segments-fetcher.ts @@ -0,0 +1,25 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { + UserSegmentCollection, + CollectionParams, + FeatureSegmentStatus +} from '@types'; +import { isNotEmpty } from 'utils/data-type'; +import { stringifyParams } from 'utils/search-params'; + +export interface UserSegmentsFetcherParams extends CollectionParams { + environmentId?: string; + isInUseStatus?: boolean; + status?: FeatureSegmentStatus; +} + +export const userSegmentsFetcher = async ( + params?: UserSegmentsFetcherParams +): Promise => { + const requestParams = stringifyParams(pickBy(params, v => isNotEmpty(v))); + + return axiosClient + .get(`/v1/segments?${requestParams}`) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@icons/index.tsx b/ui/dashboard/src/@icons/index.tsx index 80df7c7a60..feb3484304 100644 --- a/ui/dashboard/src/@icons/index.tsx +++ b/ui/dashboard/src/@icons/index.tsx @@ -33,6 +33,7 @@ import IconUsage from './sidebar-icons/usage.svg?react'; import IconUser from './sidebar-icons/user.svg?react'; import IconDelete from './special-icons/delete.svg?react'; import IconFCM from './special-icons/fcm.svg?react'; +import IconFlagConnected from './special-icons/flag-connected.svg?react'; import IconGithub from './special-icons/github.svg?react'; import IconGoal from './special-icons/goal.svg?react'; import IconGoogle from './special-icons/google.svg?react'; @@ -84,5 +85,6 @@ export { IconFCM, IconNoData, IconLogoutConfirm, - IconDelete + IconDelete, + IconFlagConnected }; diff --git a/ui/dashboard/src/@icons/special-icons/flag-connected.svg b/ui/dashboard/src/@icons/special-icons/flag-connected.svg new file mode 100644 index 0000000000..df19142723 --- /dev/null +++ b/ui/dashboard/src/@icons/special-icons/flag-connected.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/dashboard/src/@queries/user-segments.ts b/ui/dashboard/src/@queries/user-segments.ts new file mode 100644 index 0000000000..cab029344b --- /dev/null +++ b/ui/dashboard/src/@queries/user-segments.ts @@ -0,0 +1,56 @@ +import { + userSegmentsFetcher, + UserSegmentsFetcherParams +} from '@api/user-segment'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { UserSegmentCollection, QueryOptionsRespond } from '@types'; + +type QueryOptions = QueryOptionsRespond & { + params?: UserSegmentsFetcherParams; +}; + +export const USER_SEGMENTS_QUERY_KEY = 'user-segments'; + +export const useQueryUserSegments = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const query = useQuery({ + queryKey: [USER_SEGMENTS_QUERY_KEY, params], + queryFn: async () => { + return userSegmentsFetcher(params); + }, + ...queryOptions + }); + return query; +}; + +export const usePrefetchUserSegments = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const queryClient = useQueryClient(); + queryClient.prefetchQuery({ + queryKey: [USER_SEGMENTS_QUERY_KEY, params], + queryFn: async () => { + return userSegmentsFetcher(params); + }, + ...queryOptions + }); +}; + +export const prefetchUserSegments = ( + queryClient: QueryClient, + options?: QueryOptions +) => { + const { params, ...queryOptions } = options || {}; + queryClient.prefetchQuery({ + queryKey: [USER_SEGMENTS_QUERY_KEY, params], + queryFn: async () => { + return userSegmentsFetcher(params); + }, + ...queryOptions + }); +}; + +export const invalidateUserSegments = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: [USER_SEGMENTS_QUERY_KEY] + }); +}; diff --git a/ui/dashboard/src/@types/collection.ts b/ui/dashboard/src/@types/collection.ts index 63a3416cce..fbd0244d6b 100644 --- a/ui/dashboard/src/@types/collection.ts +++ b/ui/dashboard/src/@types/collection.ts @@ -19,7 +19,9 @@ export type OrderBy = | 'STATE' | 'LAST_SEEN' | 'CREATOR_EMAIL' - | 'ENVIRONMENT'; + | 'ENVIRONMENT' + | 'CONNECTIONS' + | 'USERS'; export type OrderDirection = 'ASC' | 'DESC'; diff --git a/ui/dashboard/src/@types/index.ts b/ui/dashboard/src/@types/index.ts index 87bb064418..74f0f41e2b 100644 --- a/ui/dashboard/src/@types/index.ts +++ b/ui/dashboard/src/@types/index.ts @@ -9,3 +9,4 @@ export * from './api-key'; export * from './notification'; export * from './push'; export * from './tag'; +export * from './user-segment'; diff --git a/ui/dashboard/src/@types/user-segment.ts b/ui/dashboard/src/@types/user-segment.ts new file mode 100644 index 0000000000..0d894491b3 --- /dev/null +++ b/ui/dashboard/src/@types/user-segment.ts @@ -0,0 +1,121 @@ +export interface UserSegmentRule { + id: string; + strategy: UserSegmentRuleStrategy; + clauses: UserSegmentRuleClauses[]; +} + +export interface RolloutStrategy { + variations: RolloutStrategyVariations[]; +} + +export interface RolloutStrategyVariations { + variation: string; + weight: number; +} + +export type UserSegmentRuleStrategy = { + type: 'FIXED' | 'ROLLOUT'; + fixedStrategy: { variation: string }; + rolloutStrategy: RolloutStrategy; +}; + +export type ClauseOperator = + | 'EQUALS' + | 'IN' + | 'ENDS_WITH' + | 'STARTS_WITH' + | 'SEGMENT' + | 'GREATER' + | 'GREATER_OR_EQUAL' + | 'LESS' + | 'LESS_OR_EQUAL' + | 'BEFORE' + | 'AFTER' + | 'FEATURE_FLAG' + | 'PARTIALLY_MATCH'; + +export interface UserSegmentRuleClauses { + id: string; + attribute: string; + operator: ClauseOperator; + values: string[]; +} + +export type FeatureSegmentStatus = + | 'INITIAL' + | 'UPLOADING' + | 'SUCEEDED' + | 'FAILED'; + +export interface FeatureVariations { + id: string; + value: string; + name: string; + description: string; +} + +export interface FeatureTarget { + variation: string; + users: string[]; +} + +export interface FeatureLastUsedInfo { + featureId: string; + version: number; + lastUsedAt: string; + createdAt: string; + clientOldestVersion: string; + clientLatestVersion: string; +} + +export interface FeaturePrerequisites { + featureId: string; + variationId: string; +} + +export interface UserSegmentFeature { + id: string; + name: string; + description: string; + enabled: true; + deleted: true; + evaluationUndelayable: true; + ttl: 0; + version: 0; + createdAt: string; + updatedAt: string; + variations: FeatureVariations[]; + targets: FeatureTarget[]; + rules: UserSegmentRule[]; + defaultStrategy: UserSegmentRuleStrategy; + offVariation: string; + tags: string[]; + lastUsedInfo: FeatureLastUsedInfo; + maintainer: string; + variationType: string; + archived: true; + prerequisites: FeaturePrerequisites[]; + samplingSeed: string; +} + +export interface UserSegment { + id: string; + name: string; + description: string; + rules: UserSegmentRule[]; + createdAt: string; + updatedAt: string; + version: string; + deleted: true; + includedUserCount: string; + excludedUserCount: string; + status: FeatureSegmentStatus; + isInUseStatus: boolean; + features: UserSegmentFeature[]; +} + +export interface UserSegmentCollection { + segments: Array; + cursor: string; + totalCount: string; +} diff --git a/ui/dashboard/src/app/index.tsx b/ui/dashboard/src/app/index.tsx index c84cefa1ac..9f4e1a9bb1 100644 --- a/ui/dashboard/src/app/index.tsx +++ b/ui/dashboard/src/app/index.tsx @@ -27,7 +27,8 @@ import { PAGE_PATH_PROJECTS, PAGE_PATH_PUSHES, PAGE_PATH_ROOT, - PAGE_PATH_SETTINGS + PAGE_PATH_SETTINGS, + PAGE_PATH_USER_SEGMENTS } from 'constants/routing'; import { i18n } from 'i18n'; import { getTokenStorage } from 'storage/token'; @@ -43,6 +44,7 @@ import SettingsPage from 'pages/settings'; import SignInPage from 'pages/signin'; import SignInEmailPage from 'pages/signin/email'; import SelectOrganizationPage from 'pages/signin/organization'; +import UserSegmentsPage from 'pages/user-segments'; import Navigation from 'components/navigation'; import Spinner from 'components/spinner'; import { OrganizationsRoot, ProjectsRoot } from './routers'; @@ -164,6 +166,10 @@ export const EnvironmentRoot = memo( element={} /> } /> + } + /> } /> ); diff --git a/ui/dashboard/src/components/button/index.tsx b/ui/dashboard/src/components/button/index.tsx index c8a2daa563..ff15c2d64b 100644 --- a/ui/dashboard/src/components/button/index.tsx +++ b/ui/dashboard/src/components/button/index.tsx @@ -31,7 +31,7 @@ const buttonVariants = cva( 'bg-accent-red-500 text-gray-50 shadow-border-accent-red-500', 'rounded-lg px-6 py-2', 'hover:bg-accent-red-600 hover:shadow-border-accent-red-600', - 'disabled:bg-accent-red-50 disabled:text-gray-500 ' + 'disabled:bg-accent-red-50 disabled:text-gray-500' ], text: [ 'text-primary-500 px-2', diff --git a/ui/dashboard/src/components/modal/slide.tsx b/ui/dashboard/src/components/modal/slide.tsx index 1cdb6252dd..bbb3f0d3c1 100644 --- a/ui/dashboard/src/components/modal/slide.tsx +++ b/ui/dashboard/src/components/modal/slide.tsx @@ -25,9 +25,12 @@ const SlideModal = ({ children, shouldCloseOnOverlayClick = true }: SliderProps) => { - const onOpenChange = useCallback((v: boolean) => { - if (v === false && shouldCloseOnOverlayClick) onClose(); - }, []); + const onOpenChange = useCallback( + (v: boolean) => { + if (v === false && shouldCloseOnOverlayClick) onClose(); + }, + [shouldCloseOnOverlayClick] + ); return ( diff --git a/ui/dashboard/src/components/popover/index.tsx b/ui/dashboard/src/components/popover/index.tsx index 30a7d0b5cb..38e8b431a8 100644 --- a/ui/dashboard/src/components/popover/index.tsx +++ b/ui/dashboard/src/components/popover/index.tsx @@ -16,6 +16,7 @@ export type PopoverOption = { icon?: FunctionComponent; label: string; description?: string; + disabled?: boolean; }; export type PopoverValue = number | string; @@ -120,6 +121,7 @@ const Popover = forwardRef( addonSlot={addonSlot} icon={item.icon} label={item.label} + disabled={item?.disabled} onClick={() => onClick && handleSelectItem(item.value)} /> ))} diff --git a/ui/dashboard/src/components/popover/popover-item.tsx b/ui/dashboard/src/components/popover/popover-item.tsx index 4a0e750f03..2b8cf6b0c6 100644 --- a/ui/dashboard/src/components/popover/popover-item.tsx +++ b/ui/dashboard/src/components/popover/popover-item.tsx @@ -7,12 +7,14 @@ import Icon from 'components/icon'; type PopoverItemWrapperProps = PropsWithChildren & { type: 'trigger' | 'item'; addonSlot?: AddonSlot; + disabled?: boolean; onClick?: () => void; }; const PopoverItemWrapper = ({ type, children, addonSlot, + disabled, onClick }: PopoverItemWrapperProps) => { if (type === 'trigger') return <>{children}; @@ -22,10 +24,14 @@ const PopoverItemWrapper = ({ 'flex cursor-pointer items-center gap-x-2 p-2 text-gray-700', 'hover:bg-primary-50 [&>*]:hover:text-primary-500', { - 'flex-row-reverse': addonSlot === 'right' + 'flex-row-reverse': addonSlot === 'right', + '!bg-transparent !text-gray-400 [&>*]:hover:!text-gray-400 cursor-not-allowed': + disabled } )} - onClick={onClick && onClick} + onClick={() => { + if (!disabled && onClick) onClick(); + }} > {children} @@ -37,6 +43,7 @@ export type PopoverItemProps = { addonSlot?: AddonSlot; icon?: FunctionComponent; label?: string; + disabled?: boolean; onClick?: () => void; }; @@ -45,13 +52,22 @@ const PopoverItem = ({ addonSlot, icon, label, + disabled, onClick }: PopoverItemProps) => { return ( - + {icon && ( diff --git a/ui/dashboard/src/constants/collection.ts b/ui/dashboard/src/constants/collection.ts index aad374040a..96f881bebf 100644 --- a/ui/dashboard/src/constants/collection.ts +++ b/ui/dashboard/src/constants/collection.ts @@ -21,5 +21,7 @@ export const sortingListFields: SortingListFields = { featureFlagCount: 'FEATURE_COUNT', creatorEmail: 'CREATOR_EMAIL', lastSeen: 'LAST_SEEN', - environment: 'ENVIRONMENT' + environment: 'ENVIRONMENT', + connections: 'CONNECTIONS', + users: 'USERS' }; diff --git a/ui/dashboard/src/pages/user-segments/collection-layout/data-collection.tsx b/ui/dashboard/src/pages/user-segments/collection-layout/data-collection.tsx new file mode 100644 index 0000000000..efd6cbd2ae --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/collection-layout/data-collection.tsx @@ -0,0 +1,175 @@ +import { useCallback } from 'react'; +import { + IconCloudDownloadOutlined, + IconDeleteOutlined, + IconEditOutlined, + IconMoreHorizOutlined +} from 'react-icons-material-design'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useTranslation } from 'i18n'; +import { UserSegment } from '@types'; +import { useFormatDateTime } from 'utils/date-time'; +import { cn } from 'utils/style'; +import { Popover } from 'components/popover'; +import Spinner from 'components/spinner'; +import { UserSegmentsActionsType } from '../types'; + +export const useColumns = ({ + segmentUploading, + onActionHandler +}: { + segmentUploading: UserSegment | null; + onActionHandler: (value: UserSegment, type: UserSegmentsActionsType) => void; +}): ColumnDef[] => { + const { t } = useTranslation(['common', 'table']); + const formatDateTime = useFormatDateTime(); + + const getUploadingStatus = useCallback( + (segment: UserSegment) => { + if (segment.status === 'UPLOADING') return true; + if (segmentUploading?.id === segment.id) return true; + }, + [segmentUploading] + ); + + return [ + { + accessorKey: 'name', + header: `${t('name')}`, + size: 350, + cell: ({ row }) => { + const segment = row.original; + return ( +
onActionHandler(segment, 'EDIT')} + className="flex items-center gap-x-2 cursor-pointer" + > +

+ {segment.name} +

+ {getUploadingStatus(segment) && } +
+ ); + } + }, + { + accessorKey: 'users', + header: `${t('users')}`, + size: 200, + cell: ({ row }) => { + const segment = row.original; + return ( +
+ {segment.includedUserCount} +
+ ); + } + }, + { + accessorKey: 'connections', + header: `${t('connections')}`, + size: 200, + cell: ({ row }) => { + const segment = row.original; + return ( +
+ segment?.features?.length && onActionHandler(segment, 'FLAG') + } + > + {segment?.features?.length} + {` ${segment?.features?.length === 1 ? 'Flag' : 'Flags'}`} +
+ ); + } + }, + { + accessorKey: 'status', + header: `${t('status')}`, + size: 150, + cell: ({ row }) => { + const segment = row.original; + const isUploading = getUploadingStatus(segment); + return ( +
+ {isUploading + ? 'Uploading' + : segment.isInUseStatus + ? 'In Use' + : 'Not In Use'} +
+ ); + } + }, + { + accessorKey: 'updatedAt', + header: t('table:updated-at'), + size: 200, + cell: ({ row }) => { + const segment = row.original; + return ( +
+ {Number(segment.updatedAt) === 0 + ? t('never') + : formatDateTime(segment.updatedAt)} +
+ ); + } + }, + { + accessorKey: 'action', + size: 60, + header: '', + meta: { + align: 'center', + style: { textAlign: 'center', fitContent: true } + }, + enableSorting: false, + cell: ({ row }) => { + const segment = row.original; + + return ( + + onActionHandler(segment, value as UserSegmentsActionsType) + } + align="end" + /> + ); + } + } + ]; +}; diff --git a/ui/dashboard/src/pages/user-segments/collection-layout/empty-collection.tsx b/ui/dashboard/src/pages/user-segments/collection-layout/empty-collection.tsx new file mode 100644 index 0000000000..e2c11d84ab --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/collection-layout/empty-collection.tsx @@ -0,0 +1,27 @@ +import { IconAddOutlined } from 'react-icons-material-design'; +import { useTranslation } from 'i18n'; +import EmptyState from 'elements/empty-state'; + +export const EmptyCollection = ({ onAdd }: { onAdd?: () => void }) => { + const { t } = useTranslation(['common', 'table']); + + return ( + + + + + {t(`table:empty.user-segments-title`)} + + + {t(`table:empty.user-segments-desc`)} + + + + + + {t(`new-user-segment`)} + + + + ); +}; diff --git a/ui/dashboard/src/pages/user-segments/collection-loader/index.tsx b/ui/dashboard/src/pages/user-segments/collection-loader/index.tsx new file mode 100644 index 0000000000..782f64bab9 --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/collection-loader/index.tsx @@ -0,0 +1,89 @@ +import { SortingState } from '@tanstack/react-table'; +import { getCurrentEnvironment, useAuth } from 'auth'; +import { LIST_PAGE_SIZE } from 'constants/app'; +import { sortingListFields } from 'constants/collection'; +import { UserSegment } from '@types'; +import Pagination from 'components/pagination'; +import CollectionEmpty from 'elements/collection/collection-empty'; +import { DataTable } from 'elements/data-table'; +import PageLayout from 'elements/page-layout'; +import { useColumns } from '../collection-layout/data-collection'; +import { EmptyCollection } from '../collection-layout/empty-collection'; +import { UserSegmentsActionsType, UserSegmentsFilters } from '../types'; +import { useFetchSegments } from './use-fetch-segment'; + +const CollectionLoader = ({ + segmentUploading, + onAdd, + filters, + setFilters, + onActionHandler +}: { + segmentUploading: UserSegment | null; + onAdd?: () => void; + filters: UserSegmentsFilters; + setFilters: (values: Partial) => void; + organizationIds?: string[]; + onActionHandler: (value: UserSegment, type: UserSegmentsActionsType) => void; +}) => { + const columns = useColumns({ segmentUploading, onActionHandler }); + const { consoleAccount } = useAuth(); + const currenEnvironment = getCurrentEnvironment(consoleAccount!); + + const { + data: collection, + isLoading, + refetch, + isError + } = useFetchSegments({ + ...filters, + environmentId: currenEnvironment.id + }); + + const onSortingChangeHandler = (sorting: SortingState) => { + const updateOrderBy = + sorting.length > 0 + ? sortingListFields[sorting[0].id] + : sortingListFields.default; + + setFilters({ + orderBy: updateOrderBy, + orderDirection: sorting[0]?.desc ? 'DESC' : 'ASC' + }); + }; + + const userSegments = collection?.segments || []; + const totalCount = Number(collection?.totalCount) || 0; + + const emptyState = ( + setFilters({ searchQuery: '' })} + empty={} + /> + ); + + return isError ? ( + + ) : ( + <> + + {totalCount > LIST_PAGE_SIZE && !isLoading && ( + setFilters({ page })} + /> + )} + + ); +}; + +export default CollectionLoader; diff --git a/ui/dashboard/src/pages/user-segments/collection-loader/use-fetch-segment.ts b/ui/dashboard/src/pages/user-segments/collection-loader/use-fetch-segment.ts new file mode 100644 index 0000000000..3afd1dd3b7 --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/collection-loader/use-fetch-segment.ts @@ -0,0 +1,39 @@ +import { useQueryUserSegments } from '@queries/user-segments'; +import { LIST_PAGE_SIZE } from 'constants/app'; +import { FeatureSegmentStatus, OrderBy, OrderDirection } from '@types'; + +export const useFetchSegments = ({ + page = 1, + pageSize, + orderBy, + searchQuery, + orderDirection, + status, + isInUseStatus, + environmentId +}: { + pageSize?: number; + page?: number; + searchQuery?: string; + orderBy?: OrderBy; + orderDirection?: OrderDirection; + disabled?: boolean; + status?: FeatureSegmentStatus; + isInUseStatus?: boolean; + environmentId: string; +}) => { + const cursor = (page - 1) * LIST_PAGE_SIZE; + + return useQueryUserSegments({ + params: { + pageSize: pageSize || LIST_PAGE_SIZE, + cursor: String(cursor), + orderBy, + orderDirection, + searchKeyword: searchQuery, + status, + isInUseStatus, + environmentId + } + }); +}; diff --git a/ui/dashboard/src/pages/user-segments/constants.ts b/ui/dashboard/src/pages/user-segments/constants.ts new file mode 100644 index 0000000000..4eab98d33f --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/constants.ts @@ -0,0 +1,2 @@ +export const SEGMENT_MAX_FILE_SIZE = 2097152; +export const SEGMENT_SUPPORTED_FORMATS = ['text/csv', 'text/plain']; diff --git a/ui/dashboard/src/pages/user-segments/form-schema.ts b/ui/dashboard/src/pages/user-segments/form-schema.ts new file mode 100644 index 0000000000..1d99402c9b --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/form-schema.ts @@ -0,0 +1,21 @@ +import * as yup from 'yup'; +import { + SEGMENT_MAX_FILE_SIZE, + SEGMENT_SUPPORTED_FORMATS +} from 'pages/user-segments/constants'; + +export const formSchema = yup.object().shape({ + name: yup.string().required(), + description: yup.string(), + id: yup.string(), + userIds: yup.string(), + file: yup + .mixed() + .nullable() + .test('fileSize', 'The maximum size of the file is 2MB', value => { + return !value || (value as File)?.size <= SEGMENT_MAX_FILE_SIZE; + }) + .test('fileType', 'The file format is not supported', value => { + return !value || SEGMENT_SUPPORTED_FORMATS.includes((value as File).type); + }) +}); diff --git a/ui/dashboard/src/pages/user-segments/index.tsx b/ui/dashboard/src/pages/user-segments/index.tsx new file mode 100644 index 0000000000..c647a87bcc --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/index.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'i18n'; +import PageHeader from 'elements/page-header'; +import PageLayout from 'elements/page-layout'; +import PageLoader from './page-loader'; + +const UserSegmentsPage = () => { + const { t } = useTranslation(['common']); + + return ( + + + + + ); +}; + +export default UserSegmentsPage; diff --git a/ui/dashboard/src/pages/user-segments/page-content.tsx b/ui/dashboard/src/pages/user-segments/page-content.tsx new file mode 100644 index 0000000000..97f9821537 --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/page-content.tsx @@ -0,0 +1,120 @@ +import { useEffect } from 'react'; +import { IconAddOutlined } from 'react-icons-material-design'; +import { getCurrentEnvironment, useAuth } from 'auth'; +import { usePartialState, useToggleOpen } from 'hooks'; +import { useTranslation } from 'i18n'; +import pickBy from 'lodash/pickBy'; +import { UserSegment } from '@types'; +import { isEmptyObject, isNotEmpty } from 'utils/data-type'; +import { useSearchParams } from 'utils/search-params'; +import Button from 'components/button'; +import Icon from 'components/icon'; +import Filter from 'elements/filter'; +import PageLayout from 'elements/page-layout'; +import CollectionLoader from './collection-loader'; +import { UserSegmentsActionsType, UserSegmentsFilters } from './types'; +import FilterUserSegmentModal from './user-segment-modal/filter-segment-modal'; + +const PageContent = ({ + segmentUploading, + onAdd, + onEdit, + onOpenFlagModal, + onDelete, + onDownload +}: { + segmentUploading: UserSegment | null; + onAdd: () => void; + onEdit: (v: UserSegment) => void; + onOpenFlagModal: (v: UserSegment) => void; + onDelete: (v: UserSegment) => void; + onDownload: (v: UserSegment) => void; +}) => { + const { t } = useTranslation(['common']); + const { consoleAccount } = useAuth(); + const currentEnvironment = getCurrentEnvironment(consoleAccount!); + + const { searchOptions, onChangSearchParams } = useSearchParams(); + const searchFilters: Partial = searchOptions; + + const defaultFilters = { + page: 1, + orderBy: 'CREATED_AT', + orderDirection: 'DESC', + ...searchFilters + } as UserSegmentsFilters; + + const [openFilterModal, onOpenFilterModal, onCloseFilterModal] = + useToggleOpen(false); + + const [filters, setFilters] = + usePartialState(defaultFilters); + + const onChangeFilters = (values: Partial) => { + const options = pickBy({ ...filters, ...values }, v => isNotEmpty(v)); + onChangSearchParams(options); + setFilters({ ...values }); + }; + + const onActionHandler = ( + segment: UserSegment, + type: UserSegmentsActionsType + ) => { + if (type === 'EDIT') return onEdit(segment); + if (type === 'FLAG') return onOpenFlagModal(segment); + if (type === 'DELETE') return onDelete(segment); + onDownload(segment); + }; + + useEffect(() => { + if (isEmptyObject(searchOptions)) { + setFilters({ ...defaultFilters }); + } + }, [searchOptions]); + + return ( + + + + {t(`new-user-segment`)} + + } + searchValue={filters.searchQuery as string} + filterCount={ + isNotEmpty(filters.isInUseStatus as boolean) ? 1 : undefined + } + onSearchChange={searchQuery => onChangeFilters({ searchQuery })} + /> + {openFilterModal && ( + { + onChangeFilters(value); + onCloseFilterModal(); + }} + onClearFilters={() => { + onChangeFilters({ isInUseStatus: undefined }); + onCloseFilterModal(); + }} + /> + )} +
+ +
+
+ ); +}; + +export default PageContent; diff --git a/ui/dashboard/src/pages/user-segments/page-loader.tsx b/ui/dashboard/src/pages/user-segments/page-loader.tsx new file mode 100644 index 0000000000..4a506e65a3 --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/page-loader.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { userSegmentBulkDownload } from '@api/user-segment'; +import { userSegmentDelete } from '@api/user-segment/user-segment-delete'; +import { invalidateUserSegments } from '@queries/user-segments'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getCurrentEnvironment, useAuth } from 'auth'; +import { useToast } from 'hooks'; +import { useToggleOpen } from 'hooks/use-toggle-open'; +import { UserSegment } from '@types'; +import PageLayout from 'elements/page-layout'; +import { EmptyCollection } from './collection-layout/empty-collection'; +import { useFetchSegments } from './collection-loader/use-fetch-segment'; +import PageContent from './page-content'; +import AddUserSegmentModal from './user-segment-modal/add-segment-modal'; +import DeleteUserSegmentModal from './user-segment-modal/delete-segment-modal'; +import EditUserSegmentModal from './user-segment-modal/edit-segment-modal'; +import FlagsConnectedModal from './user-segment-modal/flags-connected-modal'; + +const PageLoader = () => { + const [selectedSegment, setSelectedSegment] = useState(); + const [segmentUploading, setSegmentUploading] = useState( + null + ); + + const [isOpenAddModal, onOpenAddModal, onCloseAddModal] = + useToggleOpen(false); + const [isOpenEditModal, onOpenEditModal, onCloseEditModal] = + useToggleOpen(false); + const [isOpenFlagModal, onOpenFlagModal, onCloseFlagModal] = + useToggleOpen(false); + const [isOpenDeleteModal, onOpenDeleteModal, onCloseDeleteModal] = + useToggleOpen(false); + + const { consoleAccount } = useAuth(); + const currentEnvironment = getCurrentEnvironment(consoleAccount!); + const { notify } = useToast(); + const queryClient = useQueryClient(); + + const { + data: collection, + isLoading, + refetch, + isError + } = useFetchSegments({ + pageSize: 1, + environmentId: currentEnvironment.id + }); + + const isEmpty = collection?.segments.length === 0; + + const mutation = useMutation({ + mutationFn: async (selectedSegment: UserSegment) => { + return userSegmentDelete({ + id: selectedSegment.id, + environmentId: currentEnvironment.id + }); + }, + onSuccess: () => { + onCloseDeleteModal(); + invalidateUserSegments(queryClient); + notify({ + toastType: 'toast', + messageType: 'success', + message: ( + + {selectedSegment?.name} + {` has been deleted successfully!`} + + ) + }); + mutation.reset(); + } + }); + + const onDeleteSegment = () => { + if (selectedSegment) { + mutation.mutate(selectedSegment); + } + }; + + const onBulkDownloadSegment = async (segment: UserSegment) => { + const resp = await userSegmentBulkDownload({ + segmentId: segment.id, + environmentId: currentEnvironment.id + }); + if (resp.data) { + const url = window.URL.createObjectURL( + new Blob([atob(String(resp.data))]) + ); + const link = window.document.createElement('a'); + link.href = url; + link.setAttribute( + 'download', + `${currentEnvironment.name}-${segment.name}.csv` + ); + window.document.body.appendChild(link); + link.click(); + if (link.parentNode) { + link.parentNode.removeChild(link); + } + } + }; + + return ( + <> + {isLoading ? ( + + ) : isError ? ( + + ) : isEmpty ? ( + + + + ) : ( + { + setSelectedSegment(segment); + onOpenEditModal(); + }} + onOpenFlagModal={segment => { + setSelectedSegment(segment); + onOpenFlagModal(); + }} + onDelete={segment => { + setSelectedSegment(segment); + onOpenDeleteModal(); + }} + onDownload={onBulkDownloadSegment} + /> + )} + {isOpenAddModal && ( + + )} + {isOpenEditModal && selectedSegment && ( + + )} + {isOpenFlagModal && selectedSegment && ( + + )} + {isOpenDeleteModal && selectedSegment && ( + + )} + + ); +}; + +export default PageLoader; diff --git a/ui/dashboard/src/pages/user-segments/types.ts b/ui/dashboard/src/pages/user-segments/types.ts new file mode 100644 index 0000000000..56787dd6d3 --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/types.ts @@ -0,0 +1,30 @@ +import { SortingState } from '@tanstack/react-table'; +import { OrderBy, OrderDirection, Account, FeatureSegmentStatus } from '@types'; + +export interface UserSegmentsFilters { + pageSize?: number; + page?: number; + searchQuery?: string; + orderBy?: OrderBy; + orderDirection?: OrderDirection; + disabled?: boolean; + status?: FeatureSegmentStatus; + isInUseStatus?: boolean; + environmentId: string; +} + +export interface CollectionProps { + isLoading?: boolean; + onSortingChange: (v: SortingState) => void; + projects: Account[]; +} + +export type UserSegmentsActionsType = 'EDIT' | 'DELETE' | 'DOWNLOAD' | 'FLAG'; + +export type UserSegmentForm = { + id?: string; + name: string; + description?: string; + userIds?: string; + file?: unknown; +}; diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/add-segment-modal/index.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/add-segment-modal/index.tsx new file mode 100644 index 0000000000..6dda0e2afa --- /dev/null +++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/add-segment-modal/index.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react'; +import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; +import { userSegmentBulkUpload, userSegmentCreator } from '@api/user-segment'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { invalidateUserSegments } from '@queries/user-segments'; +import { useQueryClient } from '@tanstack/react-query'; +import { getCurrentEnvironment, useAuth } from 'auth'; +import { useToast } from 'hooks'; +import { useTranslation } from 'i18n'; +import { covertFileToUint8ToBase64 } from 'utils/converts'; +import { UserSegmentForm } from 'pages/user-segments/types'; +import Button from 'components/button'; +import { ButtonBar } from 'components/button-bar'; +import Divider from 'components/divider'; +import Form from 'components/form'; +import Input from 'components/input'; +import SlideModal from 'components/modal/slide'; +import { RadioGroup, RadioGroupItem } from 'components/radio'; +import TextArea from 'components/textarea'; +import Upload from 'components/upload-files'; +import { formSchema } from '../../form-schema'; + +interface AddUserSegmentModalProps { + isOpen: boolean; + onClose: () => void; +} + +const AddUserSegmentModal = ({ isOpen, onClose }: AddUserSegmentModalProps) => { + const { t } = useTranslation(['common', 'form']); + const { consoleAccount } = useAuth(); + const currentEnvironment = getCurrentEnvironment(consoleAccount!); + const { notify } = useToast(); + const queryClient = useQueryClient(); + + const [userIdsType, setUserIdsType] = useState('upload'); + const [files, setFiles] = useState([]); + + const form = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { + id: '', + name: '', + description: '', + userIds: '', + file: null + } + }); + + const { + formState: { isValid, isDirty, isSubmitting }, + trigger + } = form; + + const addSuccess = (name: string, isUpload = false) => { + if (!isUpload) { + notify({ + toastType: 'toast', + messageType: 'success', + message: ( + + {name} {` has been successfully created!`} + + ) + }); + onClose(); + } + invalidateUserSegments(queryClient); + }; + + const onAddSuccess = (name: string, isUpload = false) => { + let timerId: NodeJS.Timeout | null = null; + if (timerId) clearTimeout(timerId); + if (isUpload) + return (timerId = setTimeout(() => addSuccess(name, isUpload), 10000)); + return addSuccess(name, isUpload); + }; + + const onSubmit: SubmitHandler = async values => { + try { + const resp = await userSegmentCreator({ + environmentId: currentEnvironment.id, + name: values.name, + description: values.description + }); + + if (resp) { + let file: File | null = null; + if (values.file || files.length) { + file = (values.file as File) || files[0]; + } else if (values.userIds?.length) { + file = new File([values.userIds], 'filename.txt', { + type: 'text/plain' + }); + } + if (file) { + covertFileToUint8ToBase64(file, async base64String => { + const uploadResp = await userSegmentBulkUpload({ + segmentId: resp.segment.id, + environmentId: currentEnvironment.id, + state: 'INCLUDED', + data: base64String + }); + onAddSuccess(values.name); + if (uploadResp) onAddSuccess(values.name, true); + }); + } else onAddSuccess(values.name, false); + } + } catch (error) { + notify({ + toastType: 'toast', + messageType: 'error', + message: (error as Error)?.message || 'Something went wrong.' + }); + } + }; + + return ( + +
+

+ {t('form:general-info')} +

+ +
+ ( + + {t('name')} + + + + + + )} + /> + ( + + {t('form:description')} + +