diff --git a/ui/src/data-services/constants.ts b/ui/src/data-services/constants.ts index f01558bdf..44b77c573 100644 --- a/ui/src/data-services/constants.ts +++ b/ui/src/data-services/constants.ts @@ -4,6 +4,7 @@ export const API_ROUTES = { CAPTURES: 'captures', COLLECTIONS: 'captures/collections', DEPLOYMENTS: 'deployments', + DEVICES: 'deployments/devices', IDENTIFICATIONS: 'identifications', JOBS: 'jobs', LOGIN: 'auth/token/login', @@ -14,7 +15,9 @@ export const API_ROUTES = { PIPELINES: 'ml/pipelines', PROJECTS: 'projects', SESSIONS: 'events', + SITES: 'deployments/sites', SPECIES: 'taxa', + STORAGE: 'storage', SUMMARY: 'status/summary', USERS: 'users', } diff --git a/ui/src/data-services/hooks/deployments/useCreateDeployment.ts b/ui/src/data-services/hooks/deployments/useCreateDeployment.ts index 946fcbc75..30bfb6ab9 100644 --- a/ui/src/data-services/hooks/deployments/useCreateDeployment.ts +++ b/ui/src/data-services/hooks/deployments/useCreateDeployment.ts @@ -6,14 +6,15 @@ import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' const convertToServerFieldValues = (fieldValues: DeploymentFieldValues) => ({ - data_source: fieldValues.path, - description: fieldValues.description, + data_source_id: fieldValues.dataSourceId, + device_id: fieldValues.deviceId, events: [], name: fieldValues.name, latitude: fieldValues.latitude, longitude: fieldValues.longitude, occurrences: [], project_id: fieldValues.projectId, + research_site_id: fieldValues.siteId, }) export const useCreateDeployment = () => { diff --git a/ui/src/data-services/hooks/deployments/useUpdateDeployment.ts b/ui/src/data-services/hooks/deployments/useUpdateDeployment.ts index 2c02713a1..9db8d777b 100644 --- a/ui/src/data-services/hooks/deployments/useUpdateDeployment.ts +++ b/ui/src/data-services/hooks/deployments/useUpdateDeployment.ts @@ -6,11 +6,13 @@ import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' const convertToServerFieldValues = (fieldValues: DeploymentFieldValues) => ({ - data_source: fieldValues.path, + data_source_id: fieldValues.dataSourceId, description: fieldValues.description, + device_id: fieldValues.deviceId, name: fieldValues.name, latitude: fieldValues.latitude, longitude: fieldValues.longitude, + research_site_id: fieldValues.siteId, }) export const useUpdateDeployment = (id: string) => { diff --git a/ui/src/data-services/hooks/entities/types.ts b/ui/src/data-services/hooks/entities/types.ts new file mode 100644 index 000000000..9230a6173 --- /dev/null +++ b/ui/src/data-services/hooks/entities/types.ts @@ -0,0 +1,6 @@ +export interface EntityFieldValues { + description: string + name: string + projectId: string + customFields?: { [key: string]: string } +} diff --git a/ui/src/data-services/hooks/entities/useCreateEntity.ts b/ui/src/data-services/hooks/entities/useCreateEntity.ts new file mode 100644 index 000000000..5943248ba --- /dev/null +++ b/ui/src/data-services/hooks/entities/useCreateEntity.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_URL } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' +import { EntityFieldValues } from './types' +import { convertToServerFieldValues } from './utils' + +const SUCCESS_TIMEOUT = 1000 // Reset success after 1 second + +export const useCreateEntity = (collection: string, onSuccess?: () => void) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ + mutationFn: (fieldValues: EntityFieldValues) => + axios.post( + `${API_URL}/${collection}/`, + convertToServerFieldValues(fieldValues), + { + headers: getAuthHeader(user), + } + ), + onSuccess: () => { + queryClient.invalidateQueries([collection]) + onSuccess?.() + setTimeout(reset, SUCCESS_TIMEOUT) + }, + }) + + return { createEntity: mutateAsync, isLoading, isSuccess, error } +} diff --git a/ui/src/data-services/hooks/entities/useDeleteEntity.ts b/ui/src/data-services/hooks/entities/useDeleteEntity.ts new file mode 100644 index 000000000..4c1ee6747 --- /dev/null +++ b/ui/src/data-services/hooks/entities/useDeleteEntity.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_URL } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' + +export const useDeleteEntity = (collection: string, onSuccess?: () => void) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, error } = useMutation({ + mutationFn: (id: string) => + axios.delete(`${API_URL}/${collection}/${id}/`, { + headers: getAuthHeader(user), + }), + onSuccess: () => { + queryClient.invalidateQueries([collection]) + onSuccess?.() + }, + }) + + return { deleteEntity: mutateAsync, isLoading, isSuccess, error } +} diff --git a/ui/src/data-services/hooks/entities/useEntities.ts b/ui/src/data-services/hooks/entities/useEntities.ts new file mode 100644 index 000000000..17931e3de --- /dev/null +++ b/ui/src/data-services/hooks/entities/useEntities.ts @@ -0,0 +1,49 @@ +import { Entity, ServerEntity } from 'data-services/models/entity' +import { Storage } from 'data-services/models/storage' +import { FetchParams } from 'data-services/types' +import { getFetchUrl } from 'data-services/utils' +import { useMemo } from 'react' +import { useAuthorizedQuery } from '../auth/useAuthorizedQuery' + +const convertServerRecord = (collection: string, record: ServerEntity) => { + if (collection === 'storage') { + return new Storage(record) + } + + return new Entity(record) +} + +export const useEntities = ( + collection: string, + params?: FetchParams +): { + entities?: Entity[] + total: number + isLoading: boolean + isFetching: boolean + error?: unknown +} => { + const fetchUrl = getFetchUrl({ collection, params }) + + const { data, isLoading, isFetching, error } = useAuthorizedQuery<{ + results: ServerEntity[] + count: number + }>({ + queryKey: [collection, params], + url: fetchUrl, + }) + + const entities = useMemo( + () => + data?.results.map((record) => convertServerRecord(collection, record)), + [data] + ) + + return { + entities, + total: data?.count ?? 0, + isLoading, + isFetching, + error, + } +} diff --git a/ui/src/data-services/hooks/entities/useUpdateEntity.ts b/ui/src/data-services/hooks/entities/useUpdateEntity.ts new file mode 100644 index 000000000..087e39dd9 --- /dev/null +++ b/ui/src/data-services/hooks/entities/useUpdateEntity.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import axios from 'axios' +import { API_URL } from 'data-services/constants' +import { getAuthHeader } from 'data-services/utils' +import { useUser } from 'utils/user/userContext' +import { EntityFieldValues } from './types' +import { convertToServerFieldValues } from './utils' + +const SUCCESS_TIMEOUT = 1000 // Reset success after 1 second + +export const useUpdateEntity = ( + id: string, + collection: string, + onSuccess?: () => void +) => { + const { user } = useUser() + const queryClient = useQueryClient() + + const { mutateAsync, isLoading, isSuccess, reset, error } = useMutation({ + mutationFn: (fieldValues: EntityFieldValues) => + axios.patch( + `${API_URL}/${collection}/${id}/`, + convertToServerFieldValues(fieldValues), + { + headers: getAuthHeader(user), + } + ), + onSuccess: () => { + queryClient.invalidateQueries([collection]) + onSuccess?.() + setTimeout(reset, SUCCESS_TIMEOUT) + }, + }) + + return { updateEntity: mutateAsync, isLoading, error, isSuccess } +} diff --git a/ui/src/data-services/hooks/entities/utils.ts b/ui/src/data-services/hooks/entities/utils.ts new file mode 100644 index 000000000..eef89e8c0 --- /dev/null +++ b/ui/src/data-services/hooks/entities/utils.ts @@ -0,0 +1,10 @@ +import { EntityFieldValues } from './types' + +export const convertToServerFieldValues = (fieldValues: EntityFieldValues) => { + return { + description: fieldValues.description, + name: fieldValues.name, + project: fieldValues.projectId, + ...(fieldValues.customFields ?? {}), + } +} diff --git a/ui/src/data-services/models/deployment-details.ts b/ui/src/data-services/models/deployment-details.ts index 546a5696c..c391dd94a 100644 --- a/ui/src/data-services/models/deployment-details.ts +++ b/ui/src/data-services/models/deployment-details.ts @@ -1,13 +1,17 @@ import { Deployment, ServerDeployment } from './deployment' +import { Entity } from './entity' +import { Storage } from './storage' export type ServerDeploymentDetails = ServerDeployment & any // TODO: Update this type export interface DeploymentFieldValues { + dataSourceId?: string description: string name: string + siteId?: string + deviceId?: string latitude: number longitude: number - path: string projectId?: string } @@ -27,6 +31,12 @@ export class DeploymentDetails extends Deployment { } } + get device(): Entity | undefined { + if (this._deployment.device) { + return new Entity(this._deployment.device) + } + } + get description(): string { return this._deployment.description } @@ -35,7 +45,15 @@ export class DeploymentDetails extends Deployment { return this._exampleCaptures } - get path(): string { - return this._deployment.data_source + get dataSource(): Storage | undefined { + if (this._deployment.data_source) { + return new Storage(this._deployment.data_source) + } + } + + get site(): Entity | undefined { + if (this._deployment.research_site) { + return new Entity(this._deployment.research_site) + } } } diff --git a/ui/src/data-services/models/entity.ts b/ui/src/data-services/models/entity.ts new file mode 100644 index 000000000..62c0400ac --- /dev/null +++ b/ui/src/data-services/models/entity.ts @@ -0,0 +1,44 @@ +import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' +import { UserPermission } from 'utils/user/types' + +export type ServerEntity = any // TODO: Update this type + +export class Entity { + protected readonly _data: ServerEntity + + public constructor(entity: ServerEntity) { + this._data = entity + } + + get canDelete(): boolean { + return this._data.user_permissions.includes(UserPermission.Delete) + } + + get createdAt(): string { + return getFormatedDateTimeString({ + date: new Date(this._data.created_at), + }) + } + + get description(): string { + return this._data.description + } + + get id(): string { + return `${this._data.id}` + } + + get name(): string { + return this._data.name + } + + get updatedAt(): string | undefined { + if (!this._data.updated_at) { + return undefined + } + + return getFormatedDateTimeString({ + date: new Date(this._data.updated_at), + }) + } +} diff --git a/ui/src/data-services/models/storage.ts b/ui/src/data-services/models/storage.ts new file mode 100644 index 000000000..bb7f2121b --- /dev/null +++ b/ui/src/data-services/models/storage.ts @@ -0,0 +1,21 @@ +import { Entity } from './entity' + +export type ServerStorage = any // TODO: Update this type + +export class Storage extends Entity { + public constructor(entity: Storage) { + super(entity) + } + + get bucket(): string { + return this._data.bucket + } + + get endpointUrl(): string { + return this._data.endpoint_url + } + + get publicBaseUrl(): string { + return this._data.public_base_url + } +} diff --git a/ui/src/pages/deployment-details/deployment-details-form/config.ts b/ui/src/pages/deployment-details/deployment-details-form/config.ts index facf20806..b9c4e735e 100644 --- a/ui/src/pages/deployment-details/deployment-details-form/config.ts +++ b/ui/src/pages/deployment-details/deployment-details-form/config.ts @@ -9,6 +9,15 @@ export const config: FormConfig = { description: { label: translate(STRING.FIELD_LABEL_DESCRIPTION), }, + siteId: { + label: translate(STRING.FIELD_LABEL_SITE), + }, + deviceId: { + label: translate(STRING.FIELD_LABEL_DEVICE), + }, + dataSourceId: { + label: 'Data source', + }, latitude: { label: translate(STRING.FIELD_LABEL_LATITUDE), rules: { min: -90, max: 90 }, diff --git a/ui/src/pages/deployment-details/deployment-details-form/deployment-details-form.tsx b/ui/src/pages/deployment-details/deployment-details-form/deployment-details-form.tsx index 3264fdaa0..0785d369e 100644 --- a/ui/src/pages/deployment-details/deployment-details-form/deployment-details-form.tsx +++ b/ui/src/pages/deployment-details/deployment-details-form/deployment-details-form.tsx @@ -40,6 +40,8 @@ export const DeploymentDetailsForm = ({ values: { name: deployment.name, description: deployment.description, + deviceId: deployment.device?.id, + siteId: deployment.site?.id, }, isValid: startValid, }, @@ -52,7 +54,7 @@ export const DeploymentDetailsForm = ({ }, [Section.SourceImages]: { values: { - path: deployment.path, + dataSourceId: deployment.dataSource?.id, }, isValid: startValid, }, diff --git a/ui/src/pages/deployment-details/deployment-details-form/section-general/section-general.tsx b/ui/src/pages/deployment-details/deployment-details-form/section-general/section-general.tsx index 8df8091f3..dde863ff3 100644 --- a/ui/src/pages/deployment-details/deployment-details-form/section-general/section-general.tsx +++ b/ui/src/pages/deployment-details/deployment-details-form/section-general/section-general.tsx @@ -1,12 +1,16 @@ +import { FormController } from 'components/form/form-controller' import { FormField } from 'components/form/form-field' import { FormActions, FormRow, FormSection, } from 'components/form/layout/layout' +import { API_ROUTES } from 'data-services/constants' import { DeploymentFieldValues } from 'data-services/models/deployment-details' import { Button, ButtonTheme } from 'design-system/components/button/button' +import { InputContent } from 'design-system/components/input/input' import _ from 'lodash' +import { EntitiesPicker } from 'pages/overview/entities/entities-picker' import { useContext } from 'react' import { useForm } from 'react-hook-form' import { FormContext } from 'utils/formContext/formContext' @@ -18,7 +22,7 @@ import { Section } from '../types' type SectionGeneralFieldValues = Pick< DeploymentFieldValues, - 'name' | 'description' + 'name' | 'description' | 'siteId' | 'deviceId' > const DEFAULT_VALUES: SectionGeneralFieldValues = { @@ -52,6 +56,44 @@ export const SectionGeneral = ({ onNext }: { onNext: () => void }) => { + + ( + + + + )} + /> + ( + + + + )} + /> + - -const DEFAULT_VALUES: SectionSourceImagesFieldValues = { - path: '', -} +type SectionSourceImagesFieldValues = Pick< + DeploymentFieldValues, + 'dataSourceId' +> export const SectionSourceImages = ({ deployment, @@ -40,7 +40,6 @@ export const SectionSourceImages = ({ const { control, handleSubmit } = useForm({ defaultValues: { - ...DEFAULT_VALUES, ..._.omitBy(formState[Section.SourceImages].values, isEmpty), }, mode: 'onBlur', @@ -48,10 +47,6 @@ export const SectionSourceImages = ({ useSyncSectionStatus(Section.SourceImages, control) - const { status, refreshStatus, lastUpdated } = useConnectionStatus( - deployment?.path - ) - return ( - - ( + + + + )} /> diff --git a/ui/src/pages/deployment-details/deployment-details-info.tsx b/ui/src/pages/deployment-details/deployment-details-info.tsx index 8d8519600..6092666d3 100644 --- a/ui/src/pages/deployment-details/deployment-details-info.tsx +++ b/ui/src/pages/deployment-details/deployment-details-info.tsx @@ -8,8 +8,6 @@ import { MultiMarkerMap } from 'design-system/map/multi-marker-map/multi-marker- import { MarkerPosition } from 'design-system/map/types' import { useMemo } from 'react' import { STRING, translate } from 'utils/language' -import { ConnectionStatus } from './connection-status/connection-status' -import { useConnectionStatus } from './connection-status/useConnectionStatus' import styles from './styles.module.scss' export const DeploymentDetailsInfo = ({ @@ -21,10 +19,6 @@ export const DeploymentDetailsInfo = ({ title: string onEditClick: () => void }) => { - const { status, refreshStatus, lastUpdated } = useConnectionStatus( - deployment.path - ) - const markers = useMemo( () => [ { @@ -55,6 +49,16 @@ export const DeploymentDetailsInfo = ({ value={deployment.description} /> + + + + @@ -74,16 +78,9 @@ export const DeploymentDetailsInfo = ({ - - - { + const [isOpen, setIsOpen] = useState(false) + const { deleteEntity, isLoading, isSuccess, error } = + useDeleteEntity(collection) + + return ( + + + + + + setIsOpen(false)} + onSubmit={() => deleteEntity(id)} + /> + + + ) +} diff --git a/ui/src/pages/overview/entities/details-form/constants.ts b/ui/src/pages/overview/entities/details-form/constants.ts new file mode 100644 index 000000000..05ef6db98 --- /dev/null +++ b/ui/src/pages/overview/entities/details-form/constants.ts @@ -0,0 +1,8 @@ +import { StorageDetailsForm } from './storage-details-form' +import { DetailsFormProps } from './types' + +export const customFormMap: { + [key: string]: (props: DetailsFormProps) => JSX.Element +} = { + storage: StorageDetailsForm, +} diff --git a/ui/src/pages/overview/entities/details-form/entity-details-form.tsx b/ui/src/pages/overview/entities/details-form/entity-details-form.tsx new file mode 100644 index 000000000..504bc7579 --- /dev/null +++ b/ui/src/pages/overview/entities/details-form/entity-details-form.tsx @@ -0,0 +1,77 @@ +import { FormField } from 'components/form/form-field' +import { + FormActions, + FormError, + FormSection, +} from 'components/form/layout/layout' +import { FormConfig } from 'components/form/types' +import { Button, ButtonTheme } from 'design-system/components/button/button' +import { IconType } from 'design-system/components/icon/icon' +import { useForm } from 'react-hook-form' +import { STRING, translate } from 'utils/language' +import { useFormError } from 'utils/useFormError' +import { DetailsFormProps, FormValues } from './types' + +export const config: FormConfig = { + name: { + label: translate(STRING.FIELD_LABEL_NAME), + rules: { + required: true, + }, + }, + description: { + label: translate(STRING.FIELD_LABEL_DESCRIPTION), + }, +} + +export const EntityDetailsForm = ({ + entity, + error, + isLoading, + isSuccess, + onSubmit, +}: DetailsFormProps) => { + const { + control, + handleSubmit, + setError: setFieldError, + } = useForm({ + defaultValues: { + name: entity?.name ?? '', + description: entity?.description ?? '', + }, + mode: 'onChange', + }) + + const errorMessage = useFormError({ error, setFieldError }) + + return ( + onSubmit(values))}> + {errorMessage && ( + + )} + + + + + + + + + ) +} diff --git a/ui/src/pages/overview/entities/details-form/storage-details-form.tsx b/ui/src/pages/overview/entities/details-form/storage-details-form.tsx new file mode 100644 index 000000000..4b5e5c421 --- /dev/null +++ b/ui/src/pages/overview/entities/details-form/storage-details-form.tsx @@ -0,0 +1,127 @@ +import { FormField } from 'components/form/form-field' +import { + FormActions, + FormError, + FormSection, +} from 'components/form/layout/layout' +import { FormConfig } from 'components/form/types' +import { Storage } from 'data-services/models/storage' +import { Button, ButtonTheme } from 'design-system/components/button/button' +import { IconType } from 'design-system/components/icon/icon' +import { useForm } from 'react-hook-form' +import { STRING, translate } from 'utils/language' +import { useFormError } from 'utils/useFormError' +import { DetailsFormProps, FormValues } from './types' + +type StorageFormValues = FormValues & { + bucket: string + public_base_url: string + endpoint_url: string +} + +const config: FormConfig = { + name: { + label: translate(STRING.FIELD_LABEL_NAME), + rules: { + required: true, + }, + }, + description: { + label: translate(STRING.FIELD_LABEL_DESCRIPTION), + }, + bucket: { + label: 'Bucket', + }, + public_base_url: { + label: 'Public base URL', + }, + endpoint_url: { + label: 'Endpoint URL', + }, +} + +export const StorageDetailsForm = ({ + entity, + error, + isLoading, + isSuccess, + onSubmit, +}: DetailsFormProps) => { + const storage = entity as Storage | undefined + const { + control, + handleSubmit, + setError: setFieldError, + } = useForm({ + defaultValues: { + name: entity?.name ?? '', + description: entity?.description ?? '', + bucket: storage?.bucket ?? '', + public_base_url: storage?.publicBaseUrl ?? '', + endpoint_url: storage?.endpointUrl ?? '', + }, + mode: 'onChange', + }) + + const errorMessage = useFormError({ error, setFieldError }) + + return ( + + onSubmit({ + name: values.name, + description: values.description, + customFields: { + bucket: values.bucket, + public_base_url: values.public_base_url, + endpoint_url: values.endpoint_url, + }, + }) + )} + > + {errorMessage && ( + + )} + + + + + + + + + + + + ) +} diff --git a/ui/src/pages/overview/entities/details-form/types.ts b/ui/src/pages/overview/entities/details-form/types.ts new file mode 100644 index 000000000..687369da7 --- /dev/null +++ b/ui/src/pages/overview/entities/details-form/types.ts @@ -0,0 +1,16 @@ +import { Entity } from 'data-services/models/entity' + +export type DetailsFormProps = { + entity?: Entity + error?: unknown + isLoading?: boolean + isSuccess?: boolean + onSubmit: ( + data: FormValues & { customFields?: { [key: string]: string } } + ) => void +} + +export type FormValues = { + name: string + description: string +} diff --git a/ui/src/pages/overview/entities/entities-columns.tsx b/ui/src/pages/overview/entities/entities-columns.tsx new file mode 100644 index 000000000..0ee033aaf --- /dev/null +++ b/ui/src/pages/overview/entities/entities-columns.tsx @@ -0,0 +1,63 @@ +import { Entity } from 'data-services/models/entity' +import { BasicTableCell } from 'design-system/components/table/basic-table-cell/basic-table-cell' +import { TableColumn } from 'design-system/components/table/types' +import { STRING, translate } from 'utils/language' +import { DeleteEntityDialog } from './delete-entity-dialog' +import { EntityDetailsDialog } from './entity-details-dialog' +import styles from './styles.module.scss' + +export const columns: ( + collection: string, + type: string +) => TableColumn[] = (collection: string, type: string) => [ + { + id: 'name', + name: translate(STRING.FIELD_LABEL_NAME), + sortField: 'name', + renderCell: (item: Entity) => ( + + + + ), + }, + { + id: 'description', + name: translate(STRING.FIELD_LABEL_DESCRIPTION), + renderCell: (item: Entity) => , + }, + { + id: 'created-at', + name: translate(STRING.FIELD_LABEL_CREATED_AT), + sortField: 'created_at', + renderCell: (item: Entity) => , + }, + { + id: 'updated-at', + name: translate(STRING.FIELD_LABEL_UPDATED_AT), + sortField: 'updated_at', + renderCell: (item: Entity) => , + }, + { + id: 'actions', + name: '', + styles: { + padding: '16px', + width: '100%', + }, + renderCell: (item: Entity) => ( + + {item.canDelete && ( + + )} + + ), + }, +] diff --git a/ui/src/pages/overview/entities/entities-picker.tsx b/ui/src/pages/overview/entities/entities-picker.tsx new file mode 100644 index 000000000..3fac6eedb --- /dev/null +++ b/ui/src/pages/overview/entities/entities-picker.tsx @@ -0,0 +1,31 @@ +import { useEntities } from 'data-services/hooks/entities/useEntities' +import { Select } from 'design-system/components/select/select' +import { useParams } from 'react-router-dom' + +export const EntitiesPicker = ({ + collection, + value, + onValueChange, +}: { + collection: string + value?: string + onValueChange: (value?: string) => void +}) => { + const { projectId } = useParams() + const { entities = [], isLoading } = useEntities(collection, { + projectId: projectId as string, + }) + + return ( + ({ + value: e.id, + label: e.name, + }))} + showClear={false} + value={value} + onValueChange={onValueChange} + /> + ) +} diff --git a/ui/src/pages/overview/entities/entities.tsx b/ui/src/pages/overview/entities/entities.tsx new file mode 100644 index 000000000..a2489795c --- /dev/null +++ b/ui/src/pages/overview/entities/entities.tsx @@ -0,0 +1,62 @@ +import { FetchInfo } from 'components/fetch-info/fetch-info' +import { useEntities } from 'data-services/hooks/entities/useEntities' +import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' +import { Table } from 'design-system/components/table/table/table' +import { TableSortSettings } from 'design-system/components/table/types' +import { Error } from 'pages/error/error' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { usePagination } from 'utils/usePagination' +import { columns } from './entities-columns' +import { NewEntityDialog } from './new-entity-dialog' +import styles from './styles.module.scss' + +export const Entities = ({ + collection, + type, +}: { + collection: string + type: string +}) => { + const { projectId } = useParams() + const [sort, setSort] = useState() + const { pagination, setPage } = usePagination() + const { entities, total, isLoading, isFetching, error } = useEntities( + collection, + { + projectId, + pagination, + sort, + } + ) + + if (!isLoading && error) { + return + } + + return ( + <> + {isFetching && ( + + + + )} + + {entities?.length ? ( + + ) : null} + + > + ) +} diff --git a/ui/src/pages/overview/entities/entity-details-dialog.tsx b/ui/src/pages/overview/entities/entity-details-dialog.tsx new file mode 100644 index 000000000..cb19690ea --- /dev/null +++ b/ui/src/pages/overview/entities/entity-details-dialog.tsx @@ -0,0 +1,65 @@ +import { useUpdateEntity } from 'data-services/hooks/entities/useUpdateEntity' +import { Entity } from 'data-services/models/entity' +import * as Dialog from 'design-system/components/dialog/dialog' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { customFormMap } from './details-form/constants' +import { EntityDetailsForm } from './details-form/entity-details-form' +import styles from './styles.module.scss' + +const CLOSE_TIMEOUT = 1000 + +export const EntityDetailsDialog = ({ + collection, + entity, + type, +}: { + collection: string + entity: Entity + type: string +}) => { + const { projectId } = useParams() + const [isOpen, setIsOpen] = useState(false) + const { updateEntity, isLoading, isSuccess, error } = useUpdateEntity( + entity.id, + collection, + () => + setTimeout(() => { + setIsOpen(false) + }, CLOSE_TIMEOUT) + ) + + const label = translate(STRING.ENTITY_EDIT, { + type, + }) + + const DetailsForm = customFormMap[type] ?? EntityDetailsForm + + return ( + + + + {entity.name} + + + + + + { + updateEntity({ + ...data, + projectId: projectId as string, + }) + }} + /> + + + + ) +} diff --git a/ui/src/pages/overview/entities/new-entity-dialog.tsx b/ui/src/pages/overview/entities/new-entity-dialog.tsx new file mode 100644 index 000000000..7500f04a4 --- /dev/null +++ b/ui/src/pages/overview/entities/new-entity-dialog.tsx @@ -0,0 +1,64 @@ +import { useCreateEntity } from 'data-services/hooks/entities/useCreateEntity' +import { Button, ButtonTheme } from 'design-system/components/button/button' +import * as Dialog from 'design-system/components/dialog/dialog' +import { IconType } from 'design-system/components/icon/icon' +import { useState } from 'react' +import { useParams } from 'react-router-dom' +import { STRING, translate } from 'utils/language' +import { customFormMap } from './details-form/constants' +import { EntityDetailsForm } from './details-form/entity-details-form' +import styles from './styles.module.scss' + +const CLOSE_TIMEOUT = 1000 + +export const NewEntityDialog = ({ + collection, + type, +}: { + collection: string + type: string +}) => { + const { projectId } = useParams() + const [isOpen, setIsOpen] = useState(false) + const { createEntity, isLoading, isSuccess, error } = useCreateEntity( + collection, + () => + setTimeout(() => { + setIsOpen(false) + }, CLOSE_TIMEOUT) + ) + + const label = translate(STRING.ENTITY_CREATE, { + type, + }) + + const DetailsForm = customFormMap[type] ?? EntityDetailsForm + + return ( + + + + + + + + { + createEntity({ + ...data, + projectId: projectId as string, + }) + }} + /> + + + + ) +} diff --git a/ui/src/pages/overview/entities/styles.module.scss b/ui/src/pages/overview/entities/styles.module.scss new file mode 100644 index 000000000..8a97e79a9 --- /dev/null +++ b/ui/src/pages/overview/entities/styles.module.scss @@ -0,0 +1,36 @@ +@import 'src/design-system/variables/colors.scss'; +@import 'src/design-system/variables/typography.scss'; + +.fetchInfoWrapper { + position: absolute; + top: 0; + right: 0; +} + +.entityActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 16px; +} + +.dialogContent { + width: 480px; + max-width: 100%; + box-sizing: border-box; +} + +.dialogTrigger { + @include paragraph-medium(); + color: $color-primary-1-600; + + &:hover { + cursor: pointer; + opacity: 0.7; + } + + &:focus-visible { + box-shadow: 0 0 0 2px $color-generic-black; + } +} diff --git a/ui/src/pages/overview/overview.tsx b/ui/src/pages/overview/overview.tsx index 3198835a0..966c11363 100644 --- a/ui/src/pages/overview/overview.tsx +++ b/ui/src/pages/overview/overview.tsx @@ -1,3 +1,4 @@ +import { API_ROUTES } from 'data-services/constants' import { Project } from 'data-services/models/project' import { Icon, IconTheme, IconType } from 'design-system/components/icon/icon' import { LoadingSpinner } from 'design-system/components/loading-spinner/loading-spinner' @@ -7,6 +8,7 @@ import { useOutletContext } from 'react-router-dom' import { STRING, translate } from 'utils/language' import { Collections } from './collections/collections' import { DeploymentsMap } from './deployments-map/deployments-map' +import { Entities } from './entities/entities' import styles from './overview.module.scss' import { Pipelines } from './pipelines/pipelines' import { Summary } from './summary/summary' @@ -65,6 +67,18 @@ export const Overview = () => { value="pipelines" label={translate(STRING.TAB_ITEM_PIPELINES)} /> + + + @@ -75,6 +89,15 @@ export const Overview = () => { + + + + + + + + + > ) diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 0dbba7b18..348b6db1e 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -56,6 +56,7 @@ export enum STRING { FIELD_LABEL_DEPLOYMENT, FIELD_LABEL_DESCRIPTION, FIELD_LABEL_DETECTIONS, + FIELD_LABEL_DEVICE, FIELD_LABEL_DURATION, FIELD_LABEL_EMAIL, FIELD_LABEL_ERRORS, @@ -77,6 +78,7 @@ export enum STRING { FIELD_LABEL_SAMPLING_METHOD, FIELD_LABEL_SESSION, FIELD_LABEL_SESSIONS, + FIELD_LABEL_SITE, FIELD_LABEL_SIZE, FIELD_LABEL_SOURCE_IMAGE, FIELD_LABEL_SOURCE_IMAGES, @@ -128,10 +130,13 @@ export enum STRING { /* TAB_ITEM */ TAB_ITEM_COLLECTIONS, + TAB_ITEM_DEVICES, TAB_ITEM_FIELDS, TAB_ITEM_GALLERY, TAB_ITEM_IDENTIFICATION, TAB_ITEM_PIPELINES, + TAB_ITEM_SITES, + TAB_ITEM_STORAGE, TAB_ITEM_SUMMARY, TAB_ITEM_TABLE, @@ -209,6 +214,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_CREATED_AT]: 'Created at', [STRING.FIELD_LABEL_DATE]: 'Date', [STRING.FIELD_LABEL_DELAY]: 'Delay', + [STRING.FIELD_LABEL_DEVICE]: 'Device', [STRING.FIELD_LABEL_DEPLOYMENT]: 'Deployment', [STRING.FIELD_LABEL_DESCRIPTION]: 'Description', [STRING.FIELD_LABEL_DETECTIONS]: 'Detections', @@ -233,6 +239,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_SAMPLING_METHOD]: 'Sampling method', [STRING.FIELD_LABEL_SESSION]: 'Session', [STRING.FIELD_LABEL_SESSIONS]: 'Sessions', + [STRING.FIELD_LABEL_SITE]: 'Site', [STRING.FIELD_LABEL_SIZE]: 'Size', [STRING.FIELD_LABEL_SOURCE_IMAGE]: 'Source image', [STRING.FIELD_LABEL_SOURCE_IMAGES]: 'Source images', @@ -307,12 +314,15 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { /* TAB_ITEM */ [STRING.TAB_ITEM_COLLECTIONS]: 'Collections', + [STRING.TAB_ITEM_DEVICES]: 'Devices', [STRING.TAB_ITEM_FIELDS]: 'Fields', [STRING.TAB_ITEM_GALLERY]: 'Gallery', [STRING.TAB_ITEM_IDENTIFICATION]: 'Identification', [STRING.TAB_ITEM_PIPELINES]: 'Pipelines', - [STRING.TAB_ITEM_TABLE]: 'Table', + [STRING.TAB_ITEM_SITES]: 'Sites', + [STRING.TAB_ITEM_STORAGE]: 'Storage', [STRING.TAB_ITEM_SUMMARY]: 'Summary', + [STRING.TAB_ITEM_TABLE]: 'Table', /* JOB STATUS */ [STRING.CREATED]: 'Created',