diff --git a/.github/workflows/push-image.yaml b/.github/workflows/push-image.yaml index 5e126f12d7..ec2e54b46c 100644 --- a/.github/workflows/push-image.yaml +++ b/.github/workflows/push-image.yaml @@ -38,6 +38,8 @@ env: GAR_REGISTRY: asia-docker.pkg.dev GO_VERSION: 1.21.10 GO_BUILD_CONCURRENCY: 4 # Set the same number of apps to build. + NODE_VERSION: "22.1" + jobs: setup: @@ -81,6 +83,9 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} - name: Install go dependencies run: make vendor - name: Build web and go applications diff --git a/ui/dashboard/package.json b/ui/dashboard/package.json index e0fb581609..f42b6a11bb 100644 --- a/ui/dashboard/package.json +++ b/ui/dashboard/package.json @@ -21,16 +21,23 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.2", "@tailwindcss/forms": "^0.5.7", + "@tanstack/react-query": "^5.55.4", + "@tanstack/react-query-devtools": "^5.56.2", + "@tanstack/react-table": "^8.20.5", "@testing-library/jest-dom": "^6.4.8", "@types/js-cookie": "^3.0.6", "axios": "^1.7.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "i18next": "^23.14.0", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.1", @@ -41,11 +48,13 @@ "query-string": "^9.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", "react-hook-form": "^7.52.2", "react-i18next": "^15.0.1", "react-icons-material-design": "^1.0.4", "react-router-dom": "^6.26.0", "tailwind-merge": "^2.4.0", + "timeago.js": "^4.0.2", "web-vitals": "^4.2.3", "yup": "^1.4.0" }, @@ -75,6 +84,7 @@ "lint-staged": "^15.2.8", "postcss": "^8.4.41", "prettier-plugin-tailwindcss": "^0.6.5", + "react-hot-toast": "^2.4.1", "svgo": "^3.3.2", "tailwindcss": "^3.4.9", "typescript": "^5.5.3", diff --git a/ui/dashboard/public/locales/en/common.json b/ui/dashboard/public/locales/en/common.json index 9214d45e51..7533962469 100644 --- a/ui/dashboard/public/locales/en/common.json +++ b/ui/dashboard/public/locales/en/common.json @@ -1,6 +1,9 @@ { + "active": "Active", + "archived": "Archived", "continue": "Continue", "projects": "Projects", + "environment": "Environment", "environments": "Environments", "organizations": "Organizations", "members": "Members", @@ -13,6 +16,29 @@ "settings": "Settings", "integrations": "Integrations", "access": "Access", + "documentation": "Documentation", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "filter": "Filter", + "filters": "Filters", + "add-filter": "Add Filter", + "maintainer": "Maintainer", + "name": "Name", + "email": "Email", + "role": "Role", + "users": "Users", + "new-project": "New Project", + "update-project": "Update Project", + "create-project": "Create Project", + "new-org": "New Organization", + "update-org": "Update Organization", + "create-org": "Create Organization", + "new-env": "New Environment", + "update-env": "Update Environment", + "create-env": "Create Environment", + "organization-subtitle": "You can see all your clients data", + "setting-subtitle": "Manage settings about the current organization", "navigation": { "my-projects": "My projects", "audit-logs": "Audit logs", diff --git a/ui/dashboard/public/locales/en/form.json b/ui/dashboard/public/locales/en/form.json new file mode 100644 index 0000000000..8bcaeeb42b --- /dev/null +++ b/ui/dashboard/public/locales/en/form.json @@ -0,0 +1,26 @@ +{ + "general-info": "General Information", + "placeholder-name": "Enter name", + "placeholder-desc": "Enter description", + "placeholder-code": "Enter url code", + "placeholder-email": "Enter email", + "placeholder-role": "Select role", + "placeholder-url": "Enter URL", + "placeholder-prj-id": "Enter project id", + "placeholder-search-type": "Search notification type", + "placeholder-firebase": "Enter firebase cloud messaging API key", + "placeholder-tags": "Select", + "placeholder-search-input": "Search Input", + "url-code": "URL Code", + "owner-email": "Owner's email", + "project-id": "Project ID", + "description": "Description", + "optional": "Optional", + "require-comments-flag": "Require comments for flag changes", + "invite-member": "Invite Member", + "user-details": "User Details", + "add-to-env": "Add to Environment", + "env-access": "Environment Access", + "env-settings": "Environment Settings", + "trial": "Trial" +} diff --git a/ui/dashboard/public/locales/en/table.json b/ui/dashboard/public/locales/en/table.json new file mode 100644 index 0000000000..077c28bf16 --- /dev/null +++ b/ui/dashboard/public/locales/en/table.json @@ -0,0 +1,26 @@ +{ + "flags": "Flags", + "created-at": "Created At", + "popover": { + "edit-project": "Edit Project", + "archive-project": "Archive Project", + "edit-env": "Edit Environment", + "archive-env": "Archive Environment", + "edit-org": "Edit Organization", + "archive-org": "Archive Organization", + "unarchive-org": "UnArchive Organization", + "edit-user": "Edit User", + "archive-user": "Archive User" + }, + "empty": { + "project-title": "No registered projects", + "project-desc": "There are no registered projects. Add a new one to start managing.", + "project-org-desc": "There are no registered projects for this organization.", + "org-title": "No registered organizations", + "org-desc": "There are no registered organizations. Add a new one to start managing.", + "env-title": "No registered environments", + "env-desc": "There are no registered environments for this organization.", + "user-title": "No registered users", + "user-desc": "There are no registered users for this organization." + } +} diff --git a/ui/dashboard/public/locales/ja/common.json b/ui/dashboard/public/locales/ja/common.json index 9214d45e51..7533962469 100644 --- a/ui/dashboard/public/locales/ja/common.json +++ b/ui/dashboard/public/locales/ja/common.json @@ -1,6 +1,9 @@ { + "active": "Active", + "archived": "Archived", "continue": "Continue", "projects": "Projects", + "environment": "Environment", "environments": "Environments", "organizations": "Organizations", "members": "Members", @@ -13,6 +16,29 @@ "settings": "Settings", "integrations": "Integrations", "access": "Access", + "documentation": "Documentation", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "filter": "Filter", + "filters": "Filters", + "add-filter": "Add Filter", + "maintainer": "Maintainer", + "name": "Name", + "email": "Email", + "role": "Role", + "users": "Users", + "new-project": "New Project", + "update-project": "Update Project", + "create-project": "Create Project", + "new-org": "New Organization", + "update-org": "Update Organization", + "create-org": "Create Organization", + "new-env": "New Environment", + "update-env": "Update Environment", + "create-env": "Create Environment", + "organization-subtitle": "You can see all your clients data", + "setting-subtitle": "Manage settings about the current organization", "navigation": { "my-projects": "My projects", "audit-logs": "Audit logs", diff --git a/ui/dashboard/public/locales/ja/form.json b/ui/dashboard/public/locales/ja/form.json new file mode 100644 index 0000000000..8bcaeeb42b --- /dev/null +++ b/ui/dashboard/public/locales/ja/form.json @@ -0,0 +1,26 @@ +{ + "general-info": "General Information", + "placeholder-name": "Enter name", + "placeholder-desc": "Enter description", + "placeholder-code": "Enter url code", + "placeholder-email": "Enter email", + "placeholder-role": "Select role", + "placeholder-url": "Enter URL", + "placeholder-prj-id": "Enter project id", + "placeholder-search-type": "Search notification type", + "placeholder-firebase": "Enter firebase cloud messaging API key", + "placeholder-tags": "Select", + "placeholder-search-input": "Search Input", + "url-code": "URL Code", + "owner-email": "Owner's email", + "project-id": "Project ID", + "description": "Description", + "optional": "Optional", + "require-comments-flag": "Require comments for flag changes", + "invite-member": "Invite Member", + "user-details": "User Details", + "add-to-env": "Add to Environment", + "env-access": "Environment Access", + "env-settings": "Environment Settings", + "trial": "Trial" +} diff --git a/ui/dashboard/public/locales/ja/table.json b/ui/dashboard/public/locales/ja/table.json new file mode 100644 index 0000000000..077c28bf16 --- /dev/null +++ b/ui/dashboard/public/locales/ja/table.json @@ -0,0 +1,26 @@ +{ + "flags": "Flags", + "created-at": "Created At", + "popover": { + "edit-project": "Edit Project", + "archive-project": "Archive Project", + "edit-env": "Edit Environment", + "archive-env": "Archive Environment", + "edit-org": "Edit Organization", + "archive-org": "Archive Organization", + "unarchive-org": "UnArchive Organization", + "edit-user": "Edit User", + "archive-user": "Archive User" + }, + "empty": { + "project-title": "No registered projects", + "project-desc": "There are no registered projects. Add a new one to start managing.", + "project-org-desc": "There are no registered projects for this organization.", + "org-title": "No registered organizations", + "org-desc": "There are no registered organizations. Add a new one to start managing.", + "env-title": "No registered environments", + "env-desc": "There are no registered environments for this organization.", + "user-title": "No registered users", + "user-desc": "There are no registered users for this organization." + } +} diff --git a/ui/dashboard/src/@api/account/accounts-fetcher.ts b/ui/dashboard/src/@api/account/accounts-fetcher.ts new file mode 100644 index 0000000000..c71d59009a --- /dev/null +++ b/ui/dashboard/src/@api/account/accounts-fetcher.ts @@ -0,0 +1,22 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { AccountCollection, CollectionParams } from '@types'; +import { isNotEmpty } from 'utils/data-type'; + +export interface AccountsFetcherParams extends CollectionParams { + organizationId?: string; + organizationRole?: number; + environmentId?: string; + environmentRole?: number; +} + +export const accountsFetcher = async ( + params?: AccountsFetcherParams +): Promise => { + return axiosClient + .post( + '/v1/account/list_accounts', + pickBy(params, v => isNotEmpty(v)) + ) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/account/index.ts b/ui/dashboard/src/@api/account/index.ts index 736785fb44..fca1f7dc00 100644 --- a/ui/dashboard/src/@api/account/index.ts +++ b/ui/dashboard/src/@api/account/index.ts @@ -1,2 +1,3 @@ export * from './account-organizations-fetcher'; export * from './account-me-fetcher'; +export * from './accounts-fetcher'; diff --git a/ui/dashboard/src/@api/axios-client.ts b/ui/dashboard/src/@api/axios-client.ts index c2964825e9..2b790f24b3 100644 --- a/ui/dashboard/src/@api/axios-client.ts +++ b/ui/dashboard/src/@api/axios-client.ts @@ -42,6 +42,7 @@ axiosClient.interceptors.response.use( const newAccessToken = response.token.accessToken; setTokenStorage(response.token); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return axiosClient(originalRequest); }) .catch(err => { clearOrgIdStorage(); @@ -49,7 +50,6 @@ axiosClient.interceptors.response.use( window.location.href = PAGE_PATH_ROOT; return Promise.reject(err); }); - return axiosClient(originalRequest); } return Promise.reject(error); } diff --git a/ui/dashboard/src/@api/organization/index.ts b/ui/dashboard/src/@api/organization/index.ts new file mode 100644 index 0000000000..d4b79893e7 --- /dev/null +++ b/ui/dashboard/src/@api/organization/index.ts @@ -0,0 +1,6 @@ +export * from './organizations-fetcher'; +export * from './organization-creator'; +export * from './organization-archive'; +export * from './organization-unarchive'; +export * from './organization-updater'; +export * from './organization-details-fetcher'; diff --git a/ui/dashboard/src/@api/organization/organization-archive.ts b/ui/dashboard/src/@api/organization/organization-archive.ts new file mode 100644 index 0000000000..ec64d71ceb --- /dev/null +++ b/ui/dashboard/src/@api/organization/organization-archive.ts @@ -0,0 +1,15 @@ +import axiosClient from '@api/axios-client'; +import { AnyObject } from 'yup'; + +export interface OrganizationArchiveParams { + id: string; + command: AnyObject; +} + +export const organizationArchive = async ( + params?: OrganizationArchiveParams +) => { + return axiosClient + .post('/v1/environment/archive_organization', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/organization/organization-creator.ts b/ui/dashboard/src/@api/organization/organization-creator.ts new file mode 100644 index 0000000000..2085c2f46b --- /dev/null +++ b/ui/dashboard/src/@api/organization/organization-creator.ts @@ -0,0 +1,23 @@ +import axiosClient from '@api/axios-client'; +import { Organization } from '@types'; + +export interface OrganizationCreatorCommand { + name: string; + urlCode: string; + description?: string; + isTrial?: boolean; + isSystemAdmin: boolean; + ownerEmail: string; +} + +export interface OrganizationResponse { + organization: Array; +} + +export const organizationCreator = async ( + params?: OrganizationCreatorCommand +): Promise => { + return axiosClient + .post('/v1/environment/create_organization', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/organization/organization-details-fetcher.ts b/ui/dashboard/src/@api/organization/organization-details-fetcher.ts new file mode 100644 index 0000000000..4e3a779d45 --- /dev/null +++ b/ui/dashboard/src/@api/organization/organization-details-fetcher.ts @@ -0,0 +1,20 @@ +import axiosClient from '@api/axios-client'; +import { Organization } from '@types'; + +export interface OrganizationDetailsFetcherParams { + id: string; +} +export interface OrganizationDetailsResponse { + organization: Organization; +} + +export const organizationDetailsFetcher = async ( + params?: OrganizationDetailsFetcherParams +): Promise => { + return axiosClient + .post( + '/v1/environment/get_organization', + params + ) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/organization/organization-unarchive.ts b/ui/dashboard/src/@api/organization/organization-unarchive.ts new file mode 100644 index 0000000000..6d24a04215 --- /dev/null +++ b/ui/dashboard/src/@api/organization/organization-unarchive.ts @@ -0,0 +1,15 @@ +import axiosClient from '@api/axios-client'; +import { AnyObject } from 'yup'; + +export interface OrganizationUnArchiveParams { + id: string; + command: AnyObject; +} + +export const organizationUnArchive = async ( + params?: OrganizationUnArchiveParams +) => { + return axiosClient + .post('/v1/environment/unarchive_organization', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/organization/organization-updater.ts b/ui/dashboard/src/@api/organization/organization-updater.ts new file mode 100644 index 0000000000..443170d545 --- /dev/null +++ b/ui/dashboard/src/@api/organization/organization-updater.ts @@ -0,0 +1,22 @@ +import axiosClient from '@api/axios-client'; + +export interface OrganizationUpdateParams { + id: string; + renameCommand: { + name: string; + }; + changeDescriptionCommand: { + description?: string; + }; + changeOwnerEmailCommand: { + ownerEmail: string; + }; +} + +export const organizationUpdater = async ( + params?: OrganizationUpdateParams +) => { + return axiosClient + .post('/v1/environment/update_organization', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/organization/organizations-fetcher.ts b/ui/dashboard/src/@api/organization/organizations-fetcher.ts new file mode 100644 index 0000000000..c1312d1fe0 --- /dev/null +++ b/ui/dashboard/src/@api/organization/organizations-fetcher.ts @@ -0,0 +1,18 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { CollectionParams, OrganizationCollection } from '@types'; +import { isNotEmpty } from 'utils/data-type'; + +export interface OrganizationsFetcherParams extends CollectionParams { + archived?: boolean; +} + +export const organizationsFetcher = async ( + _params?: OrganizationsFetcherParams +): Promise => { + const params = pickBy(_params, v => isNotEmpty(v)); + + return axiosClient + .post('/v1/environment/list_organizations', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/project/index.ts b/ui/dashboard/src/@api/project/index.ts new file mode 100644 index 0000000000..11f7cdb9c5 --- /dev/null +++ b/ui/dashboard/src/@api/project/index.ts @@ -0,0 +1,3 @@ +export * from './projects-fetcher'; +export * from './project-creator'; +export * from './project-updater'; diff --git a/ui/dashboard/src/@api/project/project-creator.ts b/ui/dashboard/src/@api/project/project-creator.ts new file mode 100644 index 0000000000..0a69819ac2 --- /dev/null +++ b/ui/dashboard/src/@api/project/project-creator.ts @@ -0,0 +1,25 @@ +import axiosClient from '@api/axios-client'; +import { Project } from '@types'; + +export interface ProjectCreatorCommand { + id: string; + name: string; + urlCode: string; + description: string; +} + +export interface ProjectCreatorParams { + command: ProjectCreatorCommand; +} + +export interface ProjectResponse { + project: Array; +} + +export const projectCreator = async ( + params?: ProjectCreatorParams +): Promise => { + return axiosClient + .post('/v1/environment/create_project', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/project/project-updater.ts b/ui/dashboard/src/@api/project/project-updater.ts new file mode 100644 index 0000000000..523ef0b46f --- /dev/null +++ b/ui/dashboard/src/@api/project/project-updater.ts @@ -0,0 +1,17 @@ +import axiosClient from '@api/axios-client'; + +export interface ProjectUpdaterParams { + id: string; + changeDescriptionCommand: { + description: string; + }; + renameCommand: { + name: string; + }; +} + +export const projectUpdater = async (params?: ProjectUpdaterParams) => { + return axiosClient + .post('/v1/environment/update_project', params) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@api/project/projects-fetcher.ts b/ui/dashboard/src/@api/project/projects-fetcher.ts new file mode 100644 index 0000000000..d1cf630134 --- /dev/null +++ b/ui/dashboard/src/@api/project/projects-fetcher.ts @@ -0,0 +1,20 @@ +import axiosClient from '@api/axios-client'; +import pickBy from 'lodash/pickBy'; +import { CollectionParams, ProjectCollection } from '@types'; +import { isNotEmpty } from 'utils/data-type'; + +export interface ProjectsFetcherParams extends CollectionParams { + organizationIds?: string[]; + archived?: boolean; +} + +export const projectsFetcher = async ( + params?: ProjectsFetcherParams +): Promise => { + return axiosClient + .post( + '/v1/environment/list_projects_v2', + pickBy(params, v => isNotEmpty(v)) + ) + .then(response => response.data); +}; diff --git a/ui/dashboard/src/@icons/customized-icons/checked.svg b/ui/dashboard/src/@icons/customized-icons/checked.svg new file mode 100644 index 0000000000..4d25bee166 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/checked.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/dashboard/src/@icons/customized-icons/close.svg b/ui/dashboard/src/@icons/customized-icons/close.svg new file mode 100644 index 0000000000..73a8107d1b --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/dashboard/src/@icons/customized-icons/sorting-down.svg b/ui/dashboard/src/@icons/customized-icons/sorting-down.svg new file mode 100644 index 0000000000..21645d1b23 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/sorting-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/sorting-up.svg b/ui/dashboard/src/@icons/customized-icons/sorting-up.svg new file mode 100644 index 0000000000..a6f7ced435 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/sorting-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/sorting.svg b/ui/dashboard/src/@icons/customized-icons/sorting.svg new file mode 100644 index 0000000000..e0f7b3c709 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/sorting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/toast-error.svg b/ui/dashboard/src/@icons/customized-icons/toast-error.svg new file mode 100644 index 0000000000..daf159fcd6 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/toast-error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/toast-info.svg b/ui/dashboard/src/@icons/customized-icons/toast-info.svg new file mode 100644 index 0000000000..f40ebf31d8 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/toast-info.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/toast-success.svg b/ui/dashboard/src/@icons/customized-icons/toast-success.svg new file mode 100644 index 0000000000..d35f092807 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/toast-success.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/dashboard/src/@icons/customized-icons/toast-warning.svg b/ui/dashboard/src/@icons/customized-icons/toast-warning.svg new file mode 100644 index 0000000000..5b4885e179 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/toast-warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/dashboard/src/@icons/customized-icons/trash.svg b/ui/dashboard/src/@icons/customized-icons/trash.svg new file mode 100644 index 0000000000..627dffaa63 --- /dev/null +++ b/ui/dashboard/src/@icons/customized-icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/dashboard/src/@icons/index.tsx b/ui/dashboard/src/@icons/index.tsx index b20dcf97ab..6eae5d02a3 100644 --- a/ui/dashboard/src/@icons/index.tsx +++ b/ui/dashboard/src/@icons/index.tsx @@ -1,8 +1,18 @@ import IconBackspace from './customized-icons/backspace.svg?react'; +import IconChecked from './customized-icons/checked.svg?react'; import IconChevronRight from './customized-icons/chevron-right.svg?react'; +import IconClose from './customized-icons/close.svg?react'; import IconEmail from './customized-icons/email.svg?react'; import IconInfo from './customized-icons/info.svg?react'; import IconSearch from './customized-icons/search.svg?react'; +import IconSortingDown from './customized-icons/sorting-down.svg?react'; +import IconSortingUp from './customized-icons/sorting-up.svg?react'; +import IconSorting from './customized-icons/sorting.svg?react'; +import IconToastError from './customized-icons/toast-error.svg?react'; +import IconToastInfo from './customized-icons/toast-info.svg?react'; +import IconToastSuccess from './customized-icons/toast-success.svg?react'; +import IconToastWarning from './customized-icons/toast-warning.svg?react'; +import IconTrash from './customized-icons/trash.svg?react'; import IconUnion from './customized-icons/union.svg?react'; import IconBuilding from './sidebar-icons/building.svg?react'; import IconDebugger from './sidebar-icons/debugger.svg?react'; @@ -48,6 +58,16 @@ export { IconInfo, IconUnion, IconEmail, + IconChecked, + IconToastError, + IconToastInfo, + IconToastSuccess, + IconToastWarning, + IconClose, + IconSorting, + IconSortingUp, + IconSortingDown, + IconTrash, // Special icons IconGoal, IconGoogle, diff --git a/ui/dashboard/src/@queries/accounts.ts b/ui/dashboard/src/@queries/accounts.ts new file mode 100644 index 0000000000..c7a2338803 --- /dev/null +++ b/ui/dashboard/src/@queries/accounts.ts @@ -0,0 +1,53 @@ +import { accountsFetcher, AccountsFetcherParams } from '@api/account'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { AccountCollection, QueryOptionsRespond } from '@types'; + +type QueryOptions = QueryOptionsRespond & { + params?: AccountsFetcherParams; +}; + +export const ACCOUNTS_QUERY_KEY = 'accounts'; + +export const useQueryAccounts = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const query = useQuery({ + queryKey: [ACCOUNTS_QUERY_KEY, params], + queryFn: async () => { + return accountsFetcher(params); + }, + ...queryOptions + }); + return query; +}; + +export const usePrefetchAccounts = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const queryClient = useQueryClient(); + queryClient.prefetchQuery({ + queryKey: [ACCOUNTS_QUERY_KEY, params], + queryFn: async () => { + return accountsFetcher(params); + }, + ...queryOptions + }); +}; + +export const prefetchAccounts = ( + queryClient: QueryClient, + options?: QueryOptions +) => { + const { params, ...queryOptions } = options || {}; + queryClient.prefetchQuery({ + queryKey: [ACCOUNTS_QUERY_KEY, params], + queryFn: async () => { + return accountsFetcher(params); + }, + ...queryOptions + }); +}; + +export const invalidateAccounts = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: [ACCOUNTS_QUERY_KEY] + }); +}; diff --git a/ui/dashboard/src/@queries/organization-details.ts b/ui/dashboard/src/@queries/organization-details.ts new file mode 100644 index 0000000000..3cdfde4a5d --- /dev/null +++ b/ui/dashboard/src/@queries/organization-details.ts @@ -0,0 +1,57 @@ +import { + organizationDetailsFetcher, + OrganizationDetailsFetcherParams, + OrganizationDetailsResponse +} from '@api/organization'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { QueryOptionsRespond } from '@types'; + +type QueryOptions = QueryOptionsRespond & { + params?: OrganizationDetailsFetcherParams; +}; + +export const ORGANIZATION_DETAILS_QUERY_KEY = 'organization-details'; + +export const useQueryOrganizationDetails = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const query = useQuery({ + queryKey: [ORGANIZATION_DETAILS_QUERY_KEY, params], + queryFn: async () => { + return organizationDetailsFetcher(params); + }, + ...queryOptions + }); + return query; +}; + +export const usePrefetchOrganizationDetails = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const queryClient = useQueryClient(); + queryClient.prefetchQuery({ + queryKey: [ORGANIZATION_DETAILS_QUERY_KEY, params], + queryFn: async () => { + return organizationDetailsFetcher(params); + }, + ...queryOptions + }); +}; + +export const prefetchOrganizationDetails = ( + queryClient: QueryClient, + options?: QueryOptions +) => { + const { params, ...queryOptions } = options || {}; + queryClient.prefetchQuery({ + queryKey: [ORGANIZATION_DETAILS_QUERY_KEY, params], + queryFn: async () => { + return organizationDetailsFetcher(params); + }, + ...queryOptions + }); +}; + +export const invalidateOrganizationDetails = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: [ORGANIZATION_DETAILS_QUERY_KEY] + }); +}; diff --git a/ui/dashboard/src/@queries/organizations.ts b/ui/dashboard/src/@queries/organizations.ts new file mode 100644 index 0000000000..14044dabc5 --- /dev/null +++ b/ui/dashboard/src/@queries/organizations.ts @@ -0,0 +1,56 @@ +import { + organizationsFetcher, + OrganizationsFetcherParams +} from '@api/organization'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { OrganizationCollection, QueryOptionsRespond } from '@types'; + +type QueryOptions = QueryOptionsRespond & { + params?: OrganizationsFetcherParams; +}; + +export const ORGANIZATIONS_QUERY_KEY = 'organizations'; + +export const useQueryOrganizations = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const query = useQuery({ + queryKey: [ORGANIZATIONS_QUERY_KEY, params], + queryFn: async () => { + return organizationsFetcher(params); + }, + ...queryOptions + }); + return query; +}; + +export const usePrefetchOrganizations = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const queryClient = useQueryClient(); + queryClient.prefetchQuery({ + queryKey: [ORGANIZATIONS_QUERY_KEY, params], + queryFn: async () => { + return organizationsFetcher(params); + }, + ...queryOptions + }); +}; + +export const prefetchOrganizations = ( + queryClient: QueryClient, + options?: QueryOptions +) => { + const { params, ...queryOptions } = options || {}; + queryClient.prefetchQuery({ + queryKey: [ORGANIZATIONS_QUERY_KEY, params], + queryFn: async () => { + return organizationsFetcher(params); + }, + ...queryOptions + }); +}; + +export const invalidateOrganizations = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: [ORGANIZATIONS_QUERY_KEY] + }); +}; diff --git a/ui/dashboard/src/@queries/projects.ts b/ui/dashboard/src/@queries/projects.ts new file mode 100644 index 0000000000..1ffc95499d --- /dev/null +++ b/ui/dashboard/src/@queries/projects.ts @@ -0,0 +1,53 @@ +import { projectsFetcher, ProjectsFetcherParams } from '@api/project'; +import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ProjectCollection, QueryOptionsRespond } from '@types'; + +type QueryOptions = QueryOptionsRespond & { + params?: ProjectsFetcherParams; +}; + +export const PROJECTS_QUERY_KEY = 'projects'; + +export const useQueryProjects = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const query = useQuery({ + queryKey: [PROJECTS_QUERY_KEY, params], + queryFn: async () => { + return projectsFetcher(params); + }, + ...queryOptions + }); + return query; +}; + +export const usePrefetchProjects = (options?: QueryOptions) => { + const { params, ...queryOptions } = options || {}; + const queryClient = useQueryClient(); + queryClient.prefetchQuery({ + queryKey: [PROJECTS_QUERY_KEY, params], + queryFn: async () => { + return projectsFetcher(params); + }, + ...queryOptions + }); +}; + +export const prefetchProjects = ( + queryClient: QueryClient, + options?: QueryOptions +) => { + const { params, ...queryOptions } = options || {}; + queryClient.prefetchQuery({ + queryKey: [PROJECTS_QUERY_KEY, params], + queryFn: async () => { + return projectsFetcher(params); + }, + ...queryOptions + }); +}; + +export const invalidateProjects = (queryClient: QueryClient) => { + queryClient.invalidateQueries({ + queryKey: [PROJECTS_QUERY_KEY] + }); +}; diff --git a/ui/dashboard/src/@types/account.ts b/ui/dashboard/src/@types/account.ts new file mode 100644 index 0000000000..036c8071e0 --- /dev/null +++ b/ui/dashboard/src/@types/account.ts @@ -0,0 +1,35 @@ +import { EnvironmentRoleType } from './auth'; +import { OrganizationRole } from './organization'; + +export interface Account { + email: string; + name: string; + avatarImageUrl: string; + organizationId: string; + organizationRole: OrganizationRole; + environmentRoles: [ + { + environmentId: string; + role: EnvironmentRoleType; + } + ]; + disabled: boolean; + createdAt: string; + updatedAt: string; + searchFilters: [ + { + id: string; + name: string; + query: string; + filterTargetType: unknown; + environmentId: string; + defaultFilter: boolean; + } + ]; +} + +export interface AccountCollection { + accounts: Array; + cursor: string; + totalCount: string; +} diff --git a/ui/dashboard/src/@types/app.ts b/ui/dashboard/src/@types/app.ts index 61c679a0a9..a61f847ca7 100644 --- a/ui/dashboard/src/@types/app.ts +++ b/ui/dashboard/src/@types/app.ts @@ -1,3 +1,5 @@ +export type AddonSlot = 'left' | 'right'; + // Theme export type Color = | 'primary-600' @@ -57,4 +59,13 @@ export type AvatarColor = | 'orange' | 'red'; -export type IconSize = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'; +export type IconSize = + | 'fit' + | 'xxs' + | 'xs' + | 'sm' + | 'md' + | 'lg' + | 'xl' + | '2xl' + | '3xl'; diff --git a/ui/dashboard/src/@types/collection.ts b/ui/dashboard/src/@types/collection.ts new file mode 100644 index 0000000000..16744ec777 --- /dev/null +++ b/ui/dashboard/src/@types/collection.ts @@ -0,0 +1,36 @@ +import { UseQueryOptions } from '@tanstack/react-query'; + +export type QueryOptionsRespond = Omit, 'queryKey'>; + +export type OrderBy = + | 'DEFAULT' + | 'ID' + | 'CREATED_AT' + | 'UPDATED_AT' + | 'NAME' + | 'URL_CODE' + | 'FEATURE_COUNT' + | 'ENVIRONMENT_COUNT' + | 'PROJECT_COUNT' + | 'USER_COUNT' + | 'ROLE' + | 'EMAIL'; + +export type OrderDirection = 'ASC' | 'DESC'; + +export type CollectionStatusType = 'ACTIVE' | 'ARCHIVED'; + +export interface Collection { + data: T[]; + cursor: string; + totalCount: string; +} + +export interface CollectionParams { + pageSize: number; + cursor: string; + orderBy?: OrderBy; + orderDirection?: OrderDirection; + searchKeyword?: string; + disabled?: boolean; +} diff --git a/ui/dashboard/src/@types/environment.ts b/ui/dashboard/src/@types/environment.ts index cab8c21297..18a55db146 100644 --- a/ui/dashboard/src/@types/environment.ts +++ b/ui/dashboard/src/@types/environment.ts @@ -10,3 +10,9 @@ export interface Environment { updatedAt: string; urlCode: string; } + +export interface EnvironmentCollection { + environments: Array; + cursor: string; + totalCount: string; +} diff --git a/ui/dashboard/src/@types/index.ts b/ui/dashboard/src/@types/index.ts index 67ae4b0768..73a12a3c68 100644 --- a/ui/dashboard/src/@types/index.ts +++ b/ui/dashboard/src/@types/index.ts @@ -3,3 +3,5 @@ export * from './auth'; export * from './organization'; export * from './project'; export * from './environment'; +export * from './collection'; +export * from './account'; diff --git a/ui/dashboard/src/@types/organization.ts b/ui/dashboard/src/@types/organization.ts index aa669981f7..d149fbae9c 100644 --- a/ui/dashboard/src/@types/organization.ts +++ b/ui/dashboard/src/@types/organization.ts @@ -13,7 +13,17 @@ export interface Organization { disabled: boolean; archived: boolean; trial: boolean; - createdAt: number; - updatedAt: number; + createdAt: string; + updatedAt: string; systemAdmin: boolean; + environmentCount: number; + projectCount: number; + userCount: number; + ownerEmail: string; +} + +export interface OrganizationCollection { + Organizations: Array; + cursor: string; + totalCount: string; } diff --git a/ui/dashboard/src/@types/project.ts b/ui/dashboard/src/@types/project.ts index f5eeb22d0a..c6c4808cac 100644 --- a/ui/dashboard/src/@types/project.ts +++ b/ui/dashboard/src/@types/project.ts @@ -6,7 +6,15 @@ export interface Project { id: string; name: string; organizationId: string; + environmentCount: number; + featureFlagCount: number; trial: boolean; updatedAt: string; urlCode: string; } + +export interface ProjectCollection { + projects: Array; + cursor: string; + totalCount: string; +} diff --git a/ui/dashboard/src/app/index.tsx b/ui/dashboard/src/app/index.tsx index d84602623b..7933170a06 100644 --- a/ui/dashboard/src/app/index.tsx +++ b/ui/dashboard/src/app/index.tsx @@ -1,4 +1,5 @@ import { memo, useCallback, useEffect, useState } from 'react'; +import { I18nextProvider } from 'react-i18next'; import { BrowserRouter, Route, @@ -6,6 +7,7 @@ import { useParams, useNavigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthCallbackPage, AuthProvider, @@ -18,19 +20,26 @@ import { PAGE_PATH_AUTH_SIGNIN, PAGE_PATH_FEATURES, PAGE_PATH_NEW, + PAGE_PATH_ORGANIZATIONS, + PAGE_PATH_PROJECTS, PAGE_PATH_ROOT, - PAGE_PATH_ROOT_ALL + PAGE_PATH_ROOT_ALL, + PAGE_PATH_SETTINGS } from 'constants/routing'; +import { i18n } from 'i18n'; import { getTokenStorage } from 'storage/token'; import { v4 as uuid } from 'uuid'; import { ConsoleAccount } from '@types'; -import DashboardPage from 'pages/dashboard'; +import FeatureFlagsPage from 'pages/feature-flags'; import NotFoundPage from 'pages/not-found'; +import ProjectsPage from 'pages/projects'; +import SettingsPage from 'pages/settings'; import SignInPage from 'pages/signin'; import SignInEmailPage from 'pages/signin/email'; import SelectOrganizationPage from 'pages/signin/organization'; import Navigation from 'components/navigation'; import Spinner from 'components/spinner'; +import { OrganizationsRoot } from './routers'; export const AppLoading = () => (
@@ -38,20 +47,38 @@ export const AppLoading = () => (
); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 60 * 1000 // Set the global stale time to 30 minutes + } + } +}); + function App() { return ( - - - - } - /> - } /> - } /> - - - + + + + + + } + /> + } + /> + } /> + + + + {/* {process.env.NODE_ENV === 'development' && ( + + )} */} + + ); } @@ -73,8 +100,14 @@ export const Root = memo(() => { return (
-
+
+ {consoleAccount.isSystemAdmin && ( + } + /> + )} {`403 Access denied`} )} - } /> + } /> + } /> + } /> } /> ); diff --git a/ui/dashboard/src/app/routers.tsx b/ui/dashboard/src/app/routers.tsx new file mode 100644 index 0000000000..f7b545f058 --- /dev/null +++ b/ui/dashboard/src/app/routers.tsx @@ -0,0 +1,12 @@ +import { Route, Routes } from 'react-router-dom'; +import OrganizationDetailPage from 'pages/organization-details'; +import OrganizationsPage from 'pages/organizations'; + +export const OrganizationsRoot = () => { + return ( + + } /> + } /> + + ); +}; diff --git a/ui/dashboard/src/assets/empty-state/error.svg b/ui/dashboard/src/assets/empty-state/error.svg new file mode 100644 index 0000000000..303ab2148f --- /dev/null +++ b/ui/dashboard/src/assets/empty-state/error.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/dashboard/src/assets/empty-state/no-data.svg b/ui/dashboard/src/assets/empty-state/no-data.svg new file mode 100644 index 0000000000..bd984b59ba --- /dev/null +++ b/ui/dashboard/src/assets/empty-state/no-data.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/dashboard/src/assets/empty-state/no-search.svg b/ui/dashboard/src/assets/empty-state/no-search.svg new file mode 100644 index 0000000000..c1e75dff81 --- /dev/null +++ b/ui/dashboard/src/assets/empty-state/no-search.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/dashboard/src/components/button/index.tsx b/ui/dashboard/src/components/button/index.tsx index 5ca1032c1f..c8a2daa563 100644 --- a/ui/dashboard/src/components/button/index.tsx +++ b/ui/dashboard/src/components/button/index.tsx @@ -5,7 +5,7 @@ import { cn } from 'utils/style'; import Spinner from 'components/spinner'; const buttonVariants = cva( - 'inline-flex animate-fade gap-2 items-center justify-center duration-300 ease-out', + 'inline-flex animate-fade gap-2 items-center justify-center duration-300 ease-out whitespace-nowrap', { variants: { variant: { @@ -13,7 +13,7 @@ const buttonVariants = cva( 'bg-primary-500 text-gray-50', 'rounded-lg px-6 py-2', 'hover:bg-primary-700', - 'disabled:bg-primary-100 disabled:text-primary-50' + 'disabled:bg-primary-200 disabled:text-primary-50' ], secondary: [ 'text-primary-500 shadow-border-primary-500', @@ -93,4 +93,4 @@ const Button = forwardRef( } ); -export { Button }; +export default Button; diff --git a/ui/dashboard/src/components/checkbox/index.tsx b/ui/dashboard/src/components/checkbox/index.tsx new file mode 100644 index 0000000000..3d2eccf513 --- /dev/null +++ b/ui/dashboard/src/components/checkbox/index.tsx @@ -0,0 +1,76 @@ +import { forwardRef, Ref, useMemo } from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { v4 as uuid } from 'uuid'; +import { cn } from 'utils/style'; +import { IconChecked } from '@icons'; +import Icon from 'components/icon'; + +type CheckboxProps = CheckboxPrimitive.CheckboxProps & { + title?: string; + description?: string; + isExpand?: boolean; + isReverse?: boolean; +}; + +const Checkbox = forwardRef( + ( + { + checked, + title, + description, + isExpand, + isReverse, + onCheckedChange, + ...props + }: CheckboxProps, + ref: Ref + ) => { + const inputId = useMemo(() => uuid(), []); + + return ( +
+
+ + + + + +
+ {title && ( + + )} +
+ ); + } +); + +export default Checkbox; diff --git a/ui/dashboard/src/components/dropdown/index.tsx b/ui/dashboard/src/components/dropdown/index.tsx index f5d98c4d11..10a8d2cff2 100644 --- a/ui/dashboard/src/components/dropdown/index.tsx +++ b/ui/dashboard/src/components/dropdown/index.tsx @@ -1,117 +1,220 @@ -import type { FunctionComponent, ReactNode } from 'react'; +import { + ComponentPropsWithoutRef, + ElementRef, + forwardRef, + FunctionComponent, + ReactNode +} from 'react'; import { IconExpandMoreRound } from 'react-icons-material-design'; -import clsx from 'clsx'; -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import type { DropdownMenuContentProps } from '@radix-ui/react-dropdown-menu'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { cva } from 'class-variance-authority'; import { cn } from 'utils/style'; +import { IconSearch } from '@icons'; import Icon from 'components/icon'; -import styles from './styles.module.css'; +import Input, { InputProps } from 'components/input'; -export type DropdownOption = { - value: DropdownValue | undefined; - icon?: FunctionComponent; - label: string; - description?: string; -}; +export type DropdownValue = number | string; -export type DropdownProps = { - align?: DropdownMenuContentProps['align']; - expand?: 'full'; - addonSlot?: 'left' | 'right'; - placeholder?: string; +export type DropdownOption = { + label: string; + value: DropdownValue; icon?: FunctionComponent; - title?: string; - options: DropdownOption[]; - action?: ReactNode; - disabled?: boolean; - value?: DropdownValue | undefined; - onChange?: (value: DropdownValue, event?: Event) => void; - defaultValue?: DropdownValue | undefined; - readOnly?: boolean; - modal?: boolean; - className?: string; + description?: boolean; + haveCheckbox?: boolean; }; -const Dropdown = ({ - align = 'start', - expand, - placeholder = 'Select...', - icon, - title, - value, - onChange, - addonSlot, - options, - action, - disabled, - modal = false, - className -}: DropdownProps) => { - const selected = options.find(o => o.value === value); +const DropdownMenu = DropdownMenuPrimitive.Root; - return ( - - - {icon && ( - - - - )} - {selected ? ( - {selected.label} +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const triggerVariants = cva( + [ + 'flex items-center px-3 py-[11px] gap-x-3 w-fit border rounded-lg bg-white', + 'disabled:cursor-not-allowed disabled:border-gray-400 disabled:bg-gray-100 disabled:!shadow-none' + ], + { + variants: { + variant: { + primary: + 'border-primary-500 hover:shadow-border-primary-500 [&>*]:text-primary-500', + secondary: + 'border-gray-400 hover:shadow-border-gray-400 [&_p]:text-gray-700 [&_span]:text-gray-600 [&>i]:text-gray-500' + } + }, + defaultVariants: { + variant: 'secondary' + } + } +); + +const DropdownMenuTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + label?: string; + description?: string; + isExpand?: boolean; + placeholder?: string; + variant?: 'primary' | 'secondary'; + showArrow?: boolean; + trigger?: ReactNode; + } +>( + ( + { + className, + variant, + label, + description, + isExpand, + placeholder = '', + showArrow = true, + trigger, + ...props + }, + ref + ) => ( + +
+ {trigger ? ( + trigger + ) : label ? ( +

+ {label} {description && {description}} +

) : ( - {placeholder} +

{placeholder}

)} - - - - - - - {title && ( - - {title} - - )} - - {options.map((option, index) => ( - onChange(option.value!, event) : undefined - } - > - {option.icon && } - {option.label} - {option.description && ( -
- {option.description} -
- )} -
- ))} - {action &&
{action}
} -
-
-
- +
+ + {showArrow && } +
+ ) +); + +const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + isExpand?: boolean; + } +>(({ className, sideOffset = 4, isExpand, ...props }, ref) => ( + + + +)); + +const DropdownMenuItem = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + icon?: FunctionComponent; + isMultiselect?: boolean; + selected?: boolean; + label?: string; + value: DropdownValue; + description?: string; + closeWhenSelected?: boolean; + onSelectOption?: (value: DropdownValue, event: Event) => void; + } +>( + ( + { + className, + icon, + label, + value, + description, + closeWhenSelected = true, + onSelectOption, + ...props + }, + ref + ) => ( + { + if (!closeWhenSelected) event.preventDefault(); + return onSelectOption(value, event); + } + : undefined + } + {...props} + > + {icon && ( +
+ +
+ )} + +
+

{label}

+ {description && ( +

+ {description} +

+ )} +
+
+ ) +); + +type DropdownSearchProps = InputProps; + +const DropdownMenuSearch = ({ + value, + onChange, + ...props +}: DropdownSearchProps) => { + return ( +
+
+ +
+ +
); }; -export default Dropdown; +export { + DropdownMenu, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSearch +}; diff --git a/ui/dashboard/src/components/dropdown/styles.module.css b/ui/dashboard/src/components/dropdown/styles.module.css deleted file mode 100644 index b39d815f58..0000000000 --- a/ui/dashboard/src/components/dropdown/styles.module.css +++ /dev/null @@ -1,75 +0,0 @@ -button.trigger { - @apply typo-para-medium flex items-center rounded-lg border border-gray-400 bg-gray-50 p-3 text-gray-500; -} -button.trigger.pad-left { - @apply pl-10; -} -button.trigger.pad-right { - @apply pr-10; -} - -.icon { - @apply mr-2 flex; -} -.arrow { - @apply ml-2 flex items-center; -} -.full { - @apply flex w-full justify-between; -} -.content { - @apply max-h-[260px] overflow-auto bg-gray-50 py-4 shadow-menu; - min-width: 304px; -} -.label { - @apply typo-head-bold-small mb-4 px-4 text-center text-gray-600; -} -.selected { - @apply flex-1 truncate text-left text-gray-600; -} -.placeholder { - @apply flex-1 truncate text-left; -} -.item { - @apply typo-para-medium cursor-pointer px-6 py-2 text-gray-600; -} -.item:hover { - @apply bg-primary-50 text-primary-500; -} -.item-active { - @apply typo-head-semi-small text-primary-500; -} -.item-icon { - @apply typo-para-medium flex items-center text-gray-600; -} -.item-icon i { - @apply mr-2; -} -.item-icon.item-active { - @apply typo-para-medium; -} -.item-icon > i { - @apply text-gray-50; -} -.item-icon:hover > i { - @apply text-primary-500; -} -.item-description { - @apply typo-para-small mt-1; -} - -/* Action */ -.action { - @apply mt-2 flex items-center px-6 py-2; -} - -button.trigger:focus, -button.trigger:focus-visible { - @apply shadow-border-gray-300; -} -button.trigger:hover { - @apply text-gray-600 shadow-border-gray-300; -} -button.trigger[data-state='open'] { - @apply bg-gray-50 outline-gray-50; -} diff --git a/ui/dashboard/src/components/form/form-label.tsx b/ui/dashboard/src/components/form/form-label.tsx index 65ae510095..5db0766b0e 100644 --- a/ui/dashboard/src/components/form/form-label.tsx +++ b/ui/dashboard/src/components/form/form-label.tsx @@ -1,16 +1,19 @@ import React, { HTMLAttributes, Ref } from 'react'; +import { useTranslation } from 'i18n'; import { cn } from 'utils/style'; import { useFormField } from 'components/form'; interface FormLabelProps extends HTMLAttributes { required?: boolean; + optional?: boolean; } const FormLabel = React.forwardRef( ( - { className, children, required, ...props }: FormLabelProps, + { className, children, required, optional, ...props }: FormLabelProps, ref: Ref ) => { + const { t } = useTranslation(['form']); const { formItemId } = useFormField(); return ( @@ -21,6 +24,9 @@ const FormLabel = React.forwardRef( {...props} > {children} + {optional && ( + ({t(`optional`)}) + )} {required && *}
); diff --git a/ui/dashboard/src/components/form/form-message.tsx b/ui/dashboard/src/components/form/form-message.tsx index f628f3a345..090ba2ae20 100644 --- a/ui/dashboard/src/components/form/form-message.tsx +++ b/ui/dashboard/src/components/form/form-message.tsx @@ -17,7 +17,7 @@ const FormMessage = React.forwardRef<

{body} diff --git a/ui/dashboard/src/components/icon/index.tsx b/ui/dashboard/src/components/icon/index.tsx index c559bd0b65..02e04fb276 100644 --- a/ui/dashboard/src/components/icon/index.tsx +++ b/ui/dashboard/src/components/icon/index.tsx @@ -6,6 +6,7 @@ import type { Color, IconSize } from '@types'; export interface IconProps { color?: Color; size?: IconSize | IconSize[]; + className?: string; icon: FunctionComponent; } @@ -15,13 +16,14 @@ const getSizeCls = (size: IconSize | IconSize[]) => { return cls.join(' '); }; -const Icon = ({ color, size = 'md', icon: SvgIcon }: IconProps) => { +const Icon = ({ color, size = 'md', icon: SvgIcon, className }: IconProps) => { return ( diff --git a/ui/dashboard/src/components/modal/dialog.tsx b/ui/dashboard/src/components/modal/dialog.tsx index ff5d8b78c0..28fb9718a2 100644 --- a/ui/dashboard/src/components/modal/dialog.tsx +++ b/ui/dashboard/src/components/modal/dialog.tsx @@ -2,31 +2,31 @@ import { ReactNode, useCallback } from 'react'; import { IconCloseRound } from 'react-icons-material-design'; import * as Dialog from '@radix-ui/react-dialog'; import { cn } from 'utils/style'; -import { Button } from 'components/button'; +import Button from 'components/button'; import Divider from 'components/divider'; import Icon from 'components/icon'; export type ModalSize = 'sm' | 'md'; - -const widthBySize: Record = { sm: 496, md: 780 }; +export type ModalProps = { + size?: ModalSize; + title: string; + isOpen: boolean; + onClose: () => void; + closeOnPressEscape?: boolean; + closeOnClickOutside?: boolean; + children?: ReactNode; + className?: string; +}; const DialogModal = ({ - size = 'sm', title, isOpen, onClose, closeOnPressEscape = true, closeOnClickOutside = true, - children -}: { - size?: ModalSize; - title: string; - isOpen: boolean; - onClose: () => void; - closeOnPressEscape?: boolean; - closeOnClickOutside?: boolean; - children: ReactNode; -}) => { + children, + className +}: ModalProps) => { const onOpenChange = useCallback((v: boolean) => { if (v === false) onClose(); }, []); @@ -36,15 +36,15 @@ const DialogModal = ({ event.preventDefault() } @@ -59,6 +59,7 @@ const DialogModal = ({ {title} + - - - + {settingMenuSections.map((item, index) => ( + + ))}

void }) => {
- - + {mainMenuSections.map((item, index) => ( + + ))}
diff --git a/ui/dashboard/src/components/pagination/index.tsx b/ui/dashboard/src/components/pagination/index.tsx new file mode 100644 index 0000000000..e8db67733c --- /dev/null +++ b/ui/dashboard/src/components/pagination/index.tsx @@ -0,0 +1,43 @@ +import { LIST_PAGE_SIZE } from 'constants/app'; +import PaginationActions from './pagination-actions'; +import PaginationCell from './pagination-cell'; +import PaginationCount from './pagination-count'; +import PaginationGroup from './pagination-group'; + +export type PaginationProps = { + page: number; + pageSize?: number; + totalCount: number; + onChange: (page: number) => void; +}; + +const Pagination = ({ + pageSize = LIST_PAGE_SIZE, + totalCount, + page, + onChange +}: PaginationProps) => { + const cursor = pageSize * (page - 1); + + return ( +
+ + +
+ ); +}; + +Pagination.Cell = PaginationCell; +Pagination.Group = PaginationGroup; +Pagination.Actions = PaginationActions; +Pagination.Count = PaginationCount; + +export default Pagination; diff --git a/ui/dashboard/src/components/pagination/pagination-actions.tsx b/ui/dashboard/src/components/pagination/pagination-actions.tsx new file mode 100644 index 0000000000..2cdc9c5855 --- /dev/null +++ b/ui/dashboard/src/components/pagination/pagination-actions.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import PaginationCell from './pagination-cell'; +import PaginationGroup from './pagination-group'; + +export type PaginationActionsProps = { + pageIndex: number; + totalItems: number; + itemsPerPage: number; + onPageChange?: (page: number) => void; +}; + +const PaginationActions = ({ + totalItems, + itemsPerPage, + pageIndex, + onPageChange +}: PaginationActionsProps) => { + const [currentPage, setCurrentPage] = useState(pageIndex); + const totalPages = Math.ceil(totalItems / itemsPerPage); + const maxVisibleButtons = 5; + + const cells = () => { + let startPage, endPage; + + if (totalPages <= maxVisibleButtons) { + startPage = 1; + endPage = totalPages; + } else { + const maxPagesBeforeCurrentPage = Math.floor(maxVisibleButtons / 2); + const maxPagesAfterCurrentPage = Math.ceil(maxVisibleButtons / 2) - 1; + + if (currentPage <= maxPagesBeforeCurrentPage) { + startPage = 1; + endPage = maxVisibleButtons; + } else if (currentPage + maxPagesAfterCurrentPage >= totalPages) { + startPage = totalPages - maxVisibleButtons + 1; + endPage = totalPages; + } else { + startPage = currentPage - maxPagesBeforeCurrentPage; + endPage = currentPage + maxPagesAfterCurrentPage; + } + } + + const pages = []; + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + if (startPage > 1) { + pages.unshift('...'); + pages.unshift(1); + } + + if (endPage < totalPages) { + pages.push('...'); + pages.push(totalPages); + } + + return pages; + }; + + const handleNext = () => handlePageChange(currentPage + 1); + + const handlePrevious = () => handlePageChange(currentPage - 1); + + const handlePageChange = (page?: number) => { + if (page) { + if (page < 1 || page > totalPages) return; + setCurrentPage(page); + if (onPageChange) onPageChange(page); + } + }; + + const handleLast = () => handlePageChange(totalPages); + + const handleFirst = () => handlePageChange(1); + + const renderCell = cells(); + + return ( +
+ + + + + + {renderCell.map((value, index) => + typeof value === 'string' ? ( + + ... + + ) : ( + + ) + )} + + + + + +
+ ); +}; + +export default PaginationActions; diff --git a/ui/dashboard/src/components/pagination/pagination-cell.tsx b/ui/dashboard/src/components/pagination/pagination-cell.tsx new file mode 100644 index 0000000000..e39d8a241a --- /dev/null +++ b/ui/dashboard/src/components/pagination/pagination-cell.tsx @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import type { FunctionComponent } from 'react'; +import { + IconKeyboardArrowLeftFilled, + IconKeyboardArrowRightFilled, + IconKeyboardDoubleArrowLeftFilled, + IconKeyboardDoubleArrowRightFilled +} from 'react-icons-material-design'; +import { cva } from 'class-variance-authority'; +import { cn } from 'utils/style'; +import Icon from 'components/icon'; + +const cellVariant = cva( + ['size-8 rounded-lg flex items-center justify-center text-gray-500'], + { + variants: { + variant: { + number: ['bg-white'], + next: ['border'], + previous: ['border'], + first: ['border'], + last: ['border'] + } + }, + defaultVariants: { + variant: 'number' + } + } +); + +export type PaginationCellType = + | 'number' + | 'next' + | 'first' + | 'previous' + | 'last'; + +export type PaginationCellProps = { + checked?: boolean; + value?: number; + variant?: PaginationCellType; + disabled?: boolean; + onClick?: (value?: number) => void; +}; + +const PaginationIcon = ({ icon }: { icon: FunctionComponent }) => ( +
+ +
+); + +const PaginationCell = ({ + checked, + value, + variant = 'number', + disabled = false, + onClick +}: PaginationCellProps) => { + const variantRender = useMemo(() => { + switch (variant) { + case 'number': + return value; + case 'next': + return ; + case 'previous': + return ; + case 'first': + return ; + case 'last': + return ; + } + }, [variant]); + + return ( + + ); +}; + +export default PaginationCell; diff --git a/ui/dashboard/src/components/pagination/pagination-count.tsx b/ui/dashboard/src/components/pagination/pagination-count.tsx new file mode 100644 index 0000000000..30b6d4882f --- /dev/null +++ b/ui/dashboard/src/components/pagination/pagination-count.tsx @@ -0,0 +1,14 @@ +export type PaginationCountProps = { + totalItems: number; + value?: number; +}; + +const PaginationCount = ({ totalItems, value = 0 }: PaginationCountProps) => { + return ( +

+ Showing {value} of {totalItems} results +

+ ); +}; + +export default PaginationCount; diff --git a/ui/dashboard/src/components/pagination/pagination-group.tsx b/ui/dashboard/src/components/pagination/pagination-group.tsx new file mode 100644 index 0000000000..546db573e4 --- /dev/null +++ b/ui/dashboard/src/components/pagination/pagination-group.tsx @@ -0,0 +1,7 @@ +import { PropsWithChildren } from 'react'; + +const PaginationGroup = ({ children }: PropsWithChildren) => { + return
{children}
; +}; + +export default PaginationGroup; diff --git a/ui/dashboard/src/components/popover/index.tsx b/ui/dashboard/src/components/popover/index.tsx new file mode 100644 index 0000000000..30a7d0b5cb --- /dev/null +++ b/ui/dashboard/src/components/popover/index.tsx @@ -0,0 +1,133 @@ +import React, { + forwardRef, + ReactNode, + Ref, + useRef, + type FunctionComponent +} from 'react'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import type { PopoverContentProps } from '@radix-ui/react-popover'; +import { AddonSlot } from '@types'; +import { cn } from 'utils/style'; +import PopoverItem from './popover-item'; + +export type PopoverOption = { + value: PopoverValue; + icon?: FunctionComponent; + label: string; + description?: string; +}; + +export type PopoverValue = number | string; + +const PopoverRoot = PopoverPrimitive.Root; +const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +const PopoverClose = PopoverPrimitive.Close; + +export type PopoverProps = { + align?: PopoverContentProps['align']; + expand?: 'full'; + addonSlot?: AddonSlot; + trigger?: ReactNode; + triggerLabel?: string; + icon?: FunctionComponent; + options: PopoverOption[]; + disabled?: boolean; + value?: PopoverValue | undefined; + modal?: boolean; + className?: string; + closeWhenSelected?: boolean; + onClick?: (value: PopoverValue) => void; +}; + +const Popover = forwardRef( + ( + { + align = 'start', + expand, + trigger, + triggerLabel = '', + icon, + addonSlot, + options, + disabled, + modal = false, + className, + closeWhenSelected = true, + onClick + }: PopoverProps, + ref: Ref + ) => { + const popoverCloseRef = useRef(null); + + const handleSelectItem = (value: PopoverValue) => { + onClick!(value); + if (closeWhenSelected) popoverCloseRef?.current?.click(); + }; + + return ( + + + {trigger ? ( + trigger + ) : ( + + )} + + + + + {options.map((item, index) => ( + onClick && handleSelectItem(item.value)} + /> + ))} + + + + ); + } +); + +export { PopoverRoot, PopoverTrigger, PopoverClose, PopoverContent, Popover }; diff --git a/ui/dashboard/src/components/popover/popover-item.tsx b/ui/dashboard/src/components/popover/popover-item.tsx new file mode 100644 index 0000000000..4a0e750f03 --- /dev/null +++ b/ui/dashboard/src/components/popover/popover-item.tsx @@ -0,0 +1,64 @@ +import { FunctionComponent } from 'react'; +import { PropsWithChildren } from 'react'; +import { AddonSlot } from '@types'; +import { cn } from 'utils/style'; +import Icon from 'components/icon'; + +type PopoverItemWrapperProps = PropsWithChildren & { + type: 'trigger' | 'item'; + addonSlot?: AddonSlot; + onClick?: () => void; +}; +const PopoverItemWrapper = ({ + type, + children, + addonSlot, + onClick +}: PopoverItemWrapperProps) => { + if (type === 'trigger') return <>{children}; + return ( +
*]:hover:text-primary-500', + { + 'flex-row-reverse': addonSlot === 'right' + } + )} + onClick={onClick && onClick} + > + {children} +
+ ); +}; + +export type PopoverItemProps = { + type: 'trigger' | 'item'; + addonSlot?: AddonSlot; + icon?: FunctionComponent; + label?: string; + onClick?: () => void; +}; + +const PopoverItem = ({ + type, + addonSlot, + icon, + label, + onClick +}: PopoverItemProps) => { + return ( + + {icon && ( + + + + )} + {label && {label}} + + ); +}; + +export default PopoverItem; diff --git a/ui/dashboard/src/components/search-input/index.tsx b/ui/dashboard/src/components/search-input/index.tsx index dc7a3ca72f..ea49e3c63b 100644 --- a/ui/dashboard/src/components/search-input/index.tsx +++ b/ui/dashboard/src/components/search-input/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { IconSearch } from '@icons'; import Icon from 'components/icon'; import Input from 'components/input'; @@ -7,15 +7,17 @@ import InputGroup from 'components/input-group'; export interface SearchBarProps { placeholder: string; value: string; - onChange: (value: string) => void; disabled?: boolean; + onChange: (value: string) => void; + onKeyDown?: (e: KeyboardEvent) => void; } const SearchInput = ({ placeholder, value: defaultValue, + disabled, onChange, - disabled + onKeyDown }: SearchBarProps) => { const [searchValue, setSearchValue] = useState(defaultValue); const searchValueRef = useRef(false); @@ -53,8 +55,9 @@ const SearchInput = ({ onKeyDown && onKeyDown(e)} /> diff --git a/ui/dashboard/src/components/table/index.tsx b/ui/dashboard/src/components/table/index.tsx new file mode 100644 index 0000000000..8cd4ea96ae --- /dev/null +++ b/ui/dashboard/src/components/table/index.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { cn } from 'utils/style'; + +const TableRoot = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ ...props }, ref) => ( + +)); +TableRoot.displayName = 'Table'; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ ...props }, ref) => ); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ ...props }, ref) => ); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ ...props }, ref) => ); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ ...props }, ref) => ); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ align, className, ...props }, ref) => ( +
+)); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ ...props }, ref) => ( + +)); +TableCell.displayName = 'TableCell'; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ ...props }, ref) =>
); +TableCaption.displayName = 'TableCaption'; + +const Table = { + Root: TableRoot, + Header: TableHeader, + Head: TableHead, + Body: TableBody, + Row: TableRow, + Footer: TableFooter, + Cell: TableCell, + Caption: TableCaption +}; + +export default Table; diff --git a/ui/dashboard/src/components/tabs-link/index.tsx b/ui/dashboard/src/components/tabs-link/index.tsx new file mode 100644 index 0000000000..1d1c5849cd --- /dev/null +++ b/ui/dashboard/src/components/tabs-link/index.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { NavLink, NavLinkProps } from 'react-router-dom'; +import { cn } from 'utils/style'; + +const Tabs = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + +const TabsList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + +const TabsLink = React.forwardRef( + ({ className, to, ...props }, ref) => ( + + ) +); + +const TabsContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); + +export { Tabs, TabsList, TabsLink, TabsContent }; diff --git a/ui/dashboard/src/components/tabs/index.tsx b/ui/dashboard/src/components/tabs/index.tsx new file mode 100644 index 0000000000..3a93e7ea13 --- /dev/null +++ b/ui/dashboard/src/components/tabs/index.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { cn } from 'utils/style'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/ui/dashboard/src/components/textarea/index.tsx b/ui/dashboard/src/components/textarea/index.tsx new file mode 100644 index 0000000000..bc89c85097 --- /dev/null +++ b/ui/dashboard/src/components/textarea/index.tsx @@ -0,0 +1,37 @@ +import { forwardRef, FunctionComponent, MouseEvent, Ref } from 'react'; +import Icon from 'components/icon'; + +export type TextAreaProps = React.DetailedHTMLProps< + React.TextareaHTMLAttributes, + HTMLTextAreaElement +> & { + iconLeft?: FunctionComponent; + onClickIcon?: (e: MouseEvent) => void; +}; + +const TextArea = forwardRef( + ( + { id, iconLeft: IconLeft, onClickIcon, ...props }: TextAreaProps, + ref: Ref + ) => { + return ( +
+