From bd8710e43ab2c038e771d810f91a3da85eadef4a Mon Sep 17 00:00:00 2001
From: Ninh <39097412+steveninhle@users.noreply.github.com>
Date: Wed, 15 Jan 2025 09:36:28 +0700
Subject: [PATCH] feat(ui/dashboard): user segments management (#1426)
---
ui/dashboard/public/locales/en/common.json | 12 +-
ui/dashboard/public/locales/en/form.json | 9 +-
ui/dashboard/public/locales/en/table.json | 13 +-
ui/dashboard/public/locales/ja/common.json | 12 +-
ui/dashboard/public/locales/ja/form.json | 9 +-
ui/dashboard/public/locales/ja/table.json | 13 +-
ui/dashboard/src/@api/user-segment/index.ts | 5 +
.../user-segment-bulk-download.ts | 23 ++
.../user-segment/user-segment-bulk-upload.ts | 16 +
.../@api/user-segment/user-segment-creator.ts | 20 ++
.../@api/user-segment/user-segment-delete.ts | 17 +
.../@api/user-segment/user-segment-fetcher.ts | 23 ++
.../@api/user-segment/user-segment-updater.ts | 21 ++
.../user-segment/user-segments-fetcher.ts | 25 ++
ui/dashboard/src/@icons/index.tsx | 4 +-
.../@icons/special-icons/flag-connected.svg | 6 +
ui/dashboard/src/@queries/user-segments.ts | 56 +++
ui/dashboard/src/@types/collection.ts | 4 +-
ui/dashboard/src/@types/index.ts | 1 +
ui/dashboard/src/@types/user-segment.ts | 121 +++++++
ui/dashboard/src/app/index.tsx | 8 +-
ui/dashboard/src/components/button/index.tsx | 2 +-
ui/dashboard/src/components/modal/slide.tsx | 9 +-
ui/dashboard/src/components/popover/index.tsx | 2 +
.../src/components/popover/popover-item.tsx | 24 +-
ui/dashboard/src/constants/collection.ts | 4 +-
.../collection-layout/data-collection.tsx | 175 ++++++++++
.../collection-layout/empty-collection.tsx | 27 ++
.../user-segments/collection-loader/index.tsx | 89 +++++
.../collection-loader/use-fetch-segment.ts | 39 +++
.../src/pages/user-segments/constants.ts | 2 +
.../src/pages/user-segments/form-schema.ts | 21 ++
.../src/pages/user-segments/index.tsx | 20 ++
.../src/pages/user-segments/page-content.tsx | 120 +++++++
.../src/pages/user-segments/page-loader.tsx | 167 +++++++++
ui/dashboard/src/pages/user-segments/types.ts | 30 ++
.../add-segment-modal/index.tsx | 273 +++++++++++++++
.../delete-segment-modal/index.tsx | 79 +++++
.../edit-segment-modal/index.tsx | 321 ++++++++++++++++++
.../edit-segment-modal/segment-warning.tsx | 62 ++++
.../filter-segment-modal/index.tsx | 157 +++++++++
.../flags-connected-modal/index.tsx | 69 ++++
ui/dashboard/src/utils/converts.ts | 11 +
43 files changed, 2101 insertions(+), 20 deletions(-)
create mode 100644 ui/dashboard/src/@api/user-segment/index.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-bulk-download.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-bulk-upload.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-creator.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-delete.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-fetcher.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segment-updater.ts
create mode 100644 ui/dashboard/src/@api/user-segment/user-segments-fetcher.ts
create mode 100644 ui/dashboard/src/@icons/special-icons/flag-connected.svg
create mode 100644 ui/dashboard/src/@queries/user-segments.ts
create mode 100644 ui/dashboard/src/@types/user-segment.ts
create mode 100644 ui/dashboard/src/pages/user-segments/collection-layout/data-collection.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/collection-layout/empty-collection.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/collection-loader/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/collection-loader/use-fetch-segment.ts
create mode 100644 ui/dashboard/src/pages/user-segments/constants.ts
create mode 100644 ui/dashboard/src/pages/user-segments/form-schema.ts
create mode 100644 ui/dashboard/src/pages/user-segments/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/page-content.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/page-loader.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/types.ts
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/add-segment-modal/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/delete-segment-modal/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/segment-warning.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/filter-segment-modal/index.tsx
create mode 100644 ui/dashboard/src/pages/user-segments/user-segment-modal/flags-connected-modal/index.tsx
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')}
+
+
+
+
+
+ )}
+ />
+
+ {`${t('form:list-of-users-ids')} (${t('form:optional')})`}
+
+
+ (
+
+
+
+
+
+
+ {t('form:browse-files')}
+
+
+ {userIdsType === 'upload' && (
+
+ {
+ setFiles(files);
+ field.onChange(files?.length ? files[0] : null);
+ trigger('file');
+ }}
+ />
+
+ )}
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+
+ {t('form:enter-user-ids')}
+
+
+ {userIdsType === 'typing' && (
+
+ )}
+
+
+
+
+ )}
+ />
+
+
+
+
+ {t(`cancel`)}
+
+ }
+ secondaryButton={
+
+ {t(`submit`)}
+
+ }
+ />
+
+
+
+
+
+ );
+};
+
+export default AddUserSegmentModal;
diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/delete-segment-modal/index.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/delete-segment-modal/index.tsx
new file mode 100644
index 0000000000..85d37bfd20
--- /dev/null
+++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/delete-segment-modal/index.tsx
@@ -0,0 +1,79 @@
+import { useMemo } from 'react';
+import { Trans } from 'react-i18next';
+import { useTranslation } from 'i18n';
+import { UserSegment } from '@types';
+import { IconDelete } from '@icons';
+import Button from 'components/button';
+import { ButtonBar } from 'components/button-bar';
+import DialogModal from 'components/modal/dialog';
+import SegmentWarning from '../edit-segment-modal/segment-warning';
+
+export type DeleteUserSegmentProps = {
+ onSubmit: () => void;
+ isOpen: boolean;
+ onClose: () => void;
+ userSegment: UserSegment;
+ loading: boolean;
+};
+
+const DeleteUserSegmentModal = ({
+ onSubmit,
+ isOpen,
+ onClose,
+ userSegment,
+ loading
+}: DeleteUserSegmentProps) => {
+ const { t } = useTranslation(['common']);
+
+ const isInUseSegment = useMemo(
+ () => userSegment.isInUseStatus || userSegment.features.length > 0,
+ [userSegment]
+ );
+
+ return (
+
+
+ {isInUseSegment ? (
+
+ ) : (
+ <>
+
+
+ }}
+ />
+
+ >
+ )}
+
+
+
+ {t(`delete`)}
+
+ }
+ primaryButton={
+
+ {t(`cancel`)}
+
+ }
+ />
+
+ );
+};
+
+export default DeleteUserSegmentModal;
diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/index.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/index.tsx
new file mode 100644
index 0000000000..1d81b9161a
--- /dev/null
+++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/index.tsx
@@ -0,0 +1,321 @@
+import { useMemo, useState } from 'react';
+import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
+import { userSegmentBulkUpload } from '@api/user-segment';
+import { userSegmentUpdater } from '@api/user-segment/user-segment-updater';
+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 { UserSegment } from '@types';
+import { covertFileToUint8ToBase64 } from 'utils/converts';
+import { cn } from 'utils/style';
+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';
+import SegmentWarning from './segment-warning';
+
+interface EditUserSegmentModalProps {
+ userSegment: UserSegment;
+ isOpen: boolean;
+ onClose: () => void;
+ setSegmentUploading: (userSegment: UserSegment | null) => void;
+}
+
+const EditUserSegmentModal = ({
+ userSegment,
+ isOpen,
+ onClose,
+ setSegmentUploading
+}: EditUserSegmentModalProps) => {
+ const { t } = useTranslation(['common', 'form']);
+ const { consoleAccount } = useAuth();
+ const currentEnvironment = getCurrentEnvironment(consoleAccount!);
+ const queryClient = useQueryClient();
+ const { notify } = useToast();
+
+ const isDisabledUserIds = useMemo(
+ () => userSegment.isInUseStatus || userSegment.features?.length > 0,
+ [userSegment]
+ );
+
+ const [userIdsType, setUserIdsType] = useState(
+ isDisabledUserIds ? '' : 'upload'
+ );
+ const [files, setFiles] = useState([]);
+
+ const form = useForm({
+ resolver: yupResolver(formSchema),
+ defaultValues: {
+ id: userSegment?.id || '',
+ name: userSegment?.name || '',
+ description: userSegment?.description || '',
+ userIds: '',
+ file: null
+ }
+ });
+
+ const {
+ formState: { isValid, isDirty, isSubmitting },
+ getFieldState,
+ trigger
+ } = form;
+
+ const updateSuccess = (name: string, isUpload = false) => {
+ if (!isUpload) {
+ notify({
+ toastType: 'toast',
+ messageType: 'success',
+ message: (
+
+ {name} {` has been successfully updated!`}
+
+ )
+ });
+ onClose();
+ }
+ if (isUpload) setSegmentUploading(null);
+ invalidateUserSegments(queryClient);
+ };
+
+ const onUpdateSuccess = (name: string, isUpload = false) => {
+ let timerId: NodeJS.Timeout | null = null;
+ if (timerId) clearTimeout(timerId);
+ if (isUpload)
+ return (timerId = setTimeout(() => updateSuccess(name, isUpload), 10000));
+ return updateSuccess(name, isUpload);
+ };
+
+ const onSubmit: SubmitHandler = async values => {
+ try {
+ const { id, name, description } = values;
+
+ 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 => {
+ await userSegmentBulkUpload({
+ segmentId: id as string,
+ environmentId: currentEnvironment.id,
+ state: 'INCLUDED',
+ data: base64String
+ });
+ onUpdateSuccess(name, true);
+ });
+ }
+
+ if (
+ getFieldState('name').isDirty ||
+ getFieldState('description').isDirty
+ ) {
+ await userSegmentUpdater({
+ id: id as string,
+ name,
+ description,
+ environmentId: currentEnvironment.id
+ });
+ }
+ if (file) setSegmentUploading(userSegment);
+ onUpdateSuccess(name, false);
+ } catch (error) {
+ notify({
+ toastType: 'toast',
+ messageType: 'error',
+ message: (error as Error)?.message || 'Something went wrong.'
+ });
+ }
+ };
+
+ return (
+
+
+
+ {t('form:update-user-segment')}
+
+
+ {t('form:general-info')}
+
+
+ (
+
+ {t('name')}
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t('form:description')}
+
+
+
+
+
+ )}
+ />
+
+ {`${t('form:list-of-users-ids')} (${t('form:optional')})`}
+
+ (
+
+
+
+
+
+
+ {t('form:browse-files')}
+
+
+ {userIdsType === 'upload' && (
+
+ {
+ setFiles(files);
+ field.onChange(files?.length ? files[0] : null);
+ trigger('file');
+ }}
+ />
+
+ )}
+
+
+
+
+ )}
+ />
+ (
+
+
+
+
+
+
+ {t('form:enter-user-ids')}
+
+
+ {userIdsType === 'typing' && (
+
+ )}
+
+
+
+
+ )}
+ />
+
+
+ {isDisabledUserIds && (
+
+ )}
+
+
+
+ {t(`common:cancel`)}
+
+ }
+ secondaryButton={
+
+ {t(`submit`)}
+
+ }
+ />
+
+
+
+
+
+ );
+};
+
+export default EditUserSegmentModal;
diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/segment-warning.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/segment-warning.tsx
new file mode 100644
index 0000000000..39aab06d8b
--- /dev/null
+++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/edit-segment-modal/segment-warning.tsx
@@ -0,0 +1,62 @@
+import { Trans } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { getCurrentEnvironment, useAuth } from 'auth';
+import { PAGE_PATH_FEATURES } from 'constants/routing';
+import { UserSegmentFeature } from '@types';
+import { cn } from 'utils/style';
+import { IconToastWarning } from '@icons';
+import Icon from 'components/icon';
+
+const SegmentWarning = ({
+ features,
+ className
+}: {
+ features: UserSegmentFeature[];
+ className?: string;
+}) => {
+ const { consoleAccount } = useAuth();
+ const currentEnvironment = getCurrentEnvironment(consoleAccount!);
+
+ return (
+
+
+
+
+ }}
+ />
+
+
+ {features?.map((item, index) => (
+
+
{index + 1}.
+
+ {item.name}
+
+
+ ))}
+
+
+ );
+};
+
+export default SegmentWarning;
diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/filter-segment-modal/index.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/filter-segment-modal/index.tsx
new file mode 100644
index 0000000000..1a0f380210
--- /dev/null
+++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/filter-segment-modal/index.tsx
@@ -0,0 +1,157 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'i18n';
+import { isNotEmpty } from 'utils/data-type';
+import { UserSegmentsFilters } from 'pages/user-segments/types';
+import Button from 'components/button';
+import { ButtonBar } from 'components/button-bar';
+import Divider from 'components/divider';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from 'components/dropdown';
+import DialogModal from 'components/modal/dialog';
+
+export type FilterProps = {
+ onSubmit: (v: Partial) => void;
+ isOpen: boolean;
+ onClose: () => void;
+ onClearFilters: () => void;
+ filters?: Partial;
+};
+
+export interface Option {
+ value: string;
+ label: string;
+}
+
+export enum FilterTypes {
+ STATUS = 'status'
+}
+
+export enum FilterValue {
+ IN_USE = 'in-use',
+ NOT_IN_USE = 'not-in-use'
+}
+
+export const filterOptions: Option[] = [
+ {
+ value: FilterTypes.STATUS,
+ label: 'Status'
+ }
+];
+
+export const statusOptions: Option[] = [
+ {
+ value: FilterValue.IN_USE,
+ label: 'In Use'
+ },
+ {
+ value: FilterValue.NOT_IN_USE,
+ label: 'Not In Use'
+ }
+];
+
+const FilterUserSegmentModal = ({
+ onSubmit,
+ isOpen,
+ onClose,
+ onClearFilters,
+ filters
+}: FilterProps) => {
+ const { t } = useTranslation(['common']);
+ const [selectedFilterType, setSelectedFilterType] = useState();
+ const [valueOption, setValueOption] = useState ();
+
+ const onConfirmHandler = () => {
+ switch (selectedFilterType?.value) {
+ case FilterTypes.STATUS:
+ if (valueOption?.value) {
+ onSubmit({
+ isInUseStatus: valueOption?.value === FilterValue.IN_USE
+ });
+ }
+ return;
+ }
+ };
+
+ useEffect(() => {
+ if (isNotEmpty(filters?.isInUseStatus)) {
+ setSelectedFilterType(filterOptions[0]);
+ setValueOption(statusOptions[filters?.isInUseStatus ? 0 : 1]);
+ } else {
+ setSelectedFilterType(undefined);
+ setValueOption(undefined);
+ }
+ }, [filters]);
+
+ return (
+
+
+
+
+ {t(`if`)}
+
+
+
+
+
+ {filterOptions.map((item, index) => (
+ setSelectedFilterType(item)}
+ />
+ ))}
+
+
+
{`is`}
+
+
+
+ {statusOptions.map((item, index) => (
+ setValueOption(item)}
+ />
+ ))}
+
+
+
+
+
+ {t(`confirm`)}
+ }
+ primaryButton={
+
+ {t(`clear`)}
+
+ }
+ />
+
+ );
+};
+
+export default FilterUserSegmentModal;
diff --git a/ui/dashboard/src/pages/user-segments/user-segment-modal/flags-connected-modal/index.tsx b/ui/dashboard/src/pages/user-segments/user-segment-modal/flags-connected-modal/index.tsx
new file mode 100644
index 0000000000..ec3e02739b
--- /dev/null
+++ b/ui/dashboard/src/pages/user-segments/user-segment-modal/flags-connected-modal/index.tsx
@@ -0,0 +1,69 @@
+import { Link } from 'react-router-dom';
+import { getCurrentEnvironment, useAuth } from 'auth';
+import { PAGE_PATH_FEATURES } from 'constants/routing';
+import { useTranslation } from 'i18n';
+import { UserSegment } from '@types';
+import { IconFlagConnected } from '@icons';
+import Button from 'components/button';
+import { ButtonBar } from 'components/button-bar';
+import Icon from 'components/icon';
+import DialogModal from 'components/modal/dialog';
+
+export type DeleteMemberProps = {
+ segment: UserSegment;
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+const FlagsConnectedModal = ({
+ segment,
+ isOpen,
+ onClose
+}: DeleteMemberProps) => {
+ const { t } = useTranslation(['common']);
+ const { consoleAccount } = useAuth();
+ const currentEnvironment = getCurrentEnvironment(consoleAccount!);
+
+ return (
+
+
+
+
+
+ {t('flags-connected-desc')}
+
+
+
+ {segment?.features?.map((item, index) => (
+
+
{index + 1}.
+
+ {item.name}
+
+
+ ))}
+
+
+
+ {t(`close`)}
+
+ }
+ />
+
+ );
+};
+
+export default FlagsConnectedModal;
diff --git a/ui/dashboard/src/utils/converts.ts b/ui/dashboard/src/utils/converts.ts
index 88f357863e..e5a20eb7e2 100644
--- a/ui/dashboard/src/utils/converts.ts
+++ b/ui/dashboard/src/utils/converts.ts
@@ -44,6 +44,17 @@ export const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => {
};
export const covertFileToByteString = (
+ file: Blob,
+ onLoad: (data: Uint8Array) => void
+) => {
+ const reader = new FileReader();
+ reader.readAsArrayBuffer(file);
+ reader.onload = () => {
+ onLoad(new Uint8Array(reader.result as ArrayBuffer));
+ };
+};
+
+export const covertFileToUint8ToBase64 = (
file: Blob,
onLoad: (data: string) => void
) => {