diff --git a/package-lock.json b/package-lock.json
index 715eb9f9..851e1d24 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7478,9 +7478,9 @@
}
},
"node_modules/rollup": {
- "version": "2.79.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz",
- "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==",
+ "version": "2.79.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
+ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -8341,9 +8341,9 @@
}
},
"node_modules/vite": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
- "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz",
+ "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==",
"dependencies": {
"esbuild": "^0.18.10",
"postcss": "^8.4.27",
@@ -8637,9 +8637,9 @@
}
},
"node_modules/vite/node_modules/rollup": {
- "version": "3.29.4",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
- "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
+ "version": "3.29.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
+ "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"bin": {
"rollup": "dist/bin/rollup"
},
@@ -13954,9 +13954,9 @@
}
},
"rollup": {
- "version": "2.79.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz",
- "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==",
+ "version": "2.79.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
+ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"requires": {
"fsevents": "~2.3.2"
}
@@ -14557,9 +14557,9 @@
}
},
"vite": {
- "version": "4.5.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz",
- "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==",
+ "version": "4.5.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz",
+ "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==",
"requires": {
"esbuild": "^0.18.10",
"fsevents": "~2.3.2",
@@ -14568,9 +14568,9 @@
},
"dependencies": {
"rollup": {
- "version": "3.29.4",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz",
- "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==",
+ "version": "3.29.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
+ "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==",
"requires": {
"fsevents": "~2.3.2"
}
diff --git a/src/components/CreateCollection/CreateCollectionForm.jsx b/src/components/CreateCollection/CreateCollectionForm.jsx
new file mode 100644
index 00000000..6c6be876
--- /dev/null
+++ b/src/components/CreateCollection/CreateCollectionForm.jsx
@@ -0,0 +1,197 @@
+import React, { useEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+import { StepContent, Stepper, Typography, Box, StepLabel, Step, Paper, Button, TextField } from '@mui/material';
+import VectorConfig from './VectorConfig';
+import { useClient } from '../../context/client-context';
+
+const CreateCollectionForm = ({ collections, onComplete, sx, handleCreated }) => {
+ const { client: qdrantClient } = useClient();
+ const [activeStep, setActiveStep] = useState(0);
+ const [collectionName, setCollectionName] = useState('');
+ const [vectors, setVectors] = useState([
+ { dimension: '', distance: '', name: '', multivector_config: null, sparse_vectors: false },
+ ]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleNext = () => {
+ setActiveStep((prevActiveStep) => prevActiveStep + 1);
+ };
+
+ const handleBack = () => {
+ setActiveStep((prevActiveStep) => prevActiveStep - 1);
+ };
+
+ useEffect(() => {
+ if (activeStep === 2) {
+ setLoading(true);
+ const sparseVectors = vectors.filter((vector) => vector.sparse_vectors);
+ const denseVectors = vectors.filter((vector) => !vector.sparse_vectors);
+
+ const vectorConfig =
+ denseVectors.length === 1
+ ? {
+ vectors: {
+ size: parseInt(denseVectors[0].dimension),
+ distance: denseVectors[0].distance,
+ multivector_config: denseVectors[0].multivector_config,
+ },
+ }
+ : {
+ vectors: denseVectors.reduce((config, vector) => {
+ config[vector.name] = {
+ size: parseInt(vector.dimension),
+ distance: vector.distance,
+ multivector_config: vector.multivector_config,
+ };
+ return config;
+ }, {}),
+ };
+ vectorConfig.sparse_vectors = sparseVectors.reduce((sparseConfig, vector) => {
+ sparseConfig[vector.name] = {};
+ return sparseConfig;
+ }, {});
+
+ qdrantClient
+ .createCollection(collectionName, vectorConfig)
+ .then(() => {
+ setLoading(false);
+ setError(null);
+ onComplete();
+ handleCreated();
+ })
+ .catch((error) => {
+ setLoading(false);
+ setError(error);
+ });
+ }
+ }, [activeStep]);
+
+ return (
+
+
+
+ {activeStep === 0 ? 'Enter a collection name' : `Collection name: ${collectionName}`}
+
+
+
+ Step 2 - Vector config
+
+
+
+ {activeStep === 2 && (
+
+ {loading ? (
+ Creating collection...
+ ) : error ? (
+ {error.message}
+ ) : (
+ Collection created successfully 🎉
+ )}
+
+ )}
+
+ );
+};
+
+// props validation
+CreateCollectionForm.propTypes = {
+ collections: PropTypes.array,
+ onComplete: PropTypes.func.isRequired,
+ sx: PropTypes.object,
+ handleCreated: PropTypes.func,
+};
+
+export default CreateCollectionForm;
+
+const CollectionNameTextBox = ({ collectionName, setCollectionName, collections, handleNext, activeStep }) => {
+ const [formError, setFormError] = useState(false);
+ const [formMessage, setFormMessage] = useState('');
+ const textFieldRef = useRef(null);
+
+ function validateCollectionName(value) {
+ const INVALID_CHARS = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '\u{1F}'];
+
+ const invalidChar = INVALID_CHARS.find((c) => value.includes(c));
+
+ if (invalidChar !== undefined) {
+ return `Collection name cannot contain "${invalidChar}" char`;
+ } else {
+ return null;
+ }
+ }
+
+ function collectionExists(name) {
+ if (collections?.some((collection) => collection.name === name)) {
+ return `Collection with name "${name}" already exists`;
+ }
+ return null;
+ }
+
+ const MAX_COLLECTION_NAME_LENGTH = 255;
+
+ useEffect(() => {
+ if (activeStep === 0) {
+ textFieldRef.current.focus();
+ }
+ return () => {};
+ }, [activeStep]);
+
+ const handleTextChange = (event) => {
+ // if there will be more forms use schema validation instead
+ const newCollectionName = event.target.value;
+ const hasForbiddenSymbolsMessage = validateCollectionName(newCollectionName);
+ const hasForbiddenSymbols = hasForbiddenSymbolsMessage !== null;
+ const collectionExistsMessage = collectionExists(newCollectionName);
+ const collectionExistsError = collectionExistsMessage !== null;
+ const isTooShort = newCollectionName?.length < 1;
+ const isTooLong = newCollectionName?.length > MAX_COLLECTION_NAME_LENGTH;
+
+ setCollectionName(newCollectionName);
+ setFormError(isTooShort || isTooLong || hasForbiddenSymbols || collectionExistsError);
+ setFormMessage(
+ isTooShort
+ ? 'Collection name is too short'
+ : isTooLong
+ ? 'Collection name is too long'
+ : (hasForbiddenSymbolsMessage || collectionExistsMessage) ?? ''
+ );
+ };
+
+ return (
+
+
+ Collection name must be new
+
+
+
+
+
+
+ );
+};
+
+CollectionNameTextBox.propTypes = {
+ collectionName: PropTypes.string.isRequired,
+ setCollectionName: PropTypes.func.isRequired,
+ collections: PropTypes.array,
+ handleNext: PropTypes.func.isRequired,
+ activeStep: PropTypes.number.isRequired,
+};
diff --git a/src/components/CreateCollection/VectorConfig.jsx b/src/components/CreateCollection/VectorConfig.jsx
new file mode 100644
index 00000000..b843d541
--- /dev/null
+++ b/src/components/CreateCollection/VectorConfig.jsx
@@ -0,0 +1,247 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ TextField,
+ StepContent,
+ InputLabel,
+ FormControl,
+ Select,
+ MenuItem,
+ FormHelperText,
+ IconButton,
+ Tooltip,
+ FormControlLabel,
+ Checkbox,
+} from '@mui/material';
+import PropTypes from 'prop-types';
+import { AddCircleOutline, DeleteOutline } from '@mui/icons-material';
+
+const VectorRow = ({ vectors, index, setVectors, errors, length, setErrors }) => {
+ const [distancesOptions, setDistancesOptions] = useState([]);
+ const handleAddVector = () => {
+ setVectors([
+ ...vectors,
+ { dimension: '', distance: '', name: '', multivector_config: null, sparse_vectors: false },
+ ]);
+ setErrors([...errors, { dimension: '', distance: '', name: '' }]);
+ };
+
+ const handleRemoveVector = (index) => {
+ const newVectors = vectors.filter((_, i) => i !== index);
+ const newErrors = errors.filter((_, i) => i !== index);
+ setVectors(newVectors);
+ setErrors(newErrors);
+ };
+
+ const validate = (index, field, value) => {
+ const newErrors = [...errors];
+ if (!value) {
+ newErrors[index][field] = `${field} is required`;
+ } else if (field === 'dimension' && isNaN(value)) {
+ newErrors[index][field] = 'Dimension must be a number';
+ } else {
+ newErrors[index][field] = '';
+ }
+ setErrors(newErrors);
+ };
+
+ const handleVectorChange = (index, field, value) => {
+ const newVectors = [...vectors];
+ newVectors[index][field] = value;
+ setVectors(newVectors);
+ validate(index, field, value);
+ };
+
+ const handleVectorConfigChange = (index, field, value) => {
+ if (value) {
+ const newVectors = [...vectors];
+
+ field === 'multivector_config'
+ ? (newVectors[index][field] = { comparator: 'max_sim' })
+ : (newVectors[index][field] = true);
+ field === 'multivector_config'
+ ? (newVectors[index].sparse_vectors = false)
+ : (newVectors[index].multivector_config = null);
+
+ setVectors(newVectors);
+ } else {
+ const newVectors = [...vectors];
+ newVectors[index][field] = null;
+ setVectors(newVectors);
+ }
+ };
+
+ useEffect(() => {
+ const getDistanceOptions = async () => {
+ const response = await fetch(import.meta.env.BASE_URL + './openapi.json');
+ const openapi = await response.json();
+ setDistancesOptions(openapi.components.schemas.Distance.enum);
+ };
+ getDistanceOptions();
+ }, []);
+
+ return (
+
+ {!vectors[index].sparse_vectors && (
+ <>
+
+ Distance
+
+ {errors[index].distance}
+
+ handleVectorChange(index, 'dimension', e.target.value)}
+ error={!!errors[index].dimension}
+ helperText={errors[index].dimension}
+ sx={{ mr: 2 }}
+ />
+ >
+ )}
+
+ {(length > 1 || vectors[index].sparse_vectors) && (
+ handleVectorChange(index, 'name', e.target.value)}
+ error={!!errors[index].name}
+ helperText={errors[index].name}
+ sx={{ mr: 2 }}
+ />
+ )}
+ {
+ handleVectorConfigChange(index, 'multivector_config', e.target.checked);
+ }}
+ checked={!!vectors[index].multivector_config}
+ />
+ }
+ label="Mutivector"
+ labelPlacement="start"
+ />
+ {
+ handleVectorConfigChange(index, 'sparse_vectors', e.target.checked);
+ }}
+ checked={!!vectors[index].sparse_vectors}
+ />
+ }
+ label="Sparse Vectors"
+ labelPlacement="start"
+ />
+
+ {length > 1 && (
+
+ handleRemoveVector(index)} color="error">
+
+
+
+ )}
+ {length - 1 === index && (
+
+
+
+
+
+ )}
+
+ );
+};
+
+VectorRow.propTypes = {
+ vectors: PropTypes.array.isRequired,
+ errors: PropTypes.array.isRequired,
+ index: PropTypes.number.isRequired,
+ length: PropTypes.number.isRequired,
+ setVectors: PropTypes.func.isRequired,
+ setErrors: PropTypes.func.isRequired,
+};
+
+const VectorConfig = ({ handleNext, handleBack, vectors, setVectors }) => {
+ const [errors, setErrors] = useState([{ dimension: '', distance: '', name: '' }]);
+
+ const validateAll = () => {
+ const newErrors = vectors.map((vector) => {
+ const error = {};
+ if (!vector.sparse_vectors) {
+ if (!vector.dimension) {
+ error.dimension = 'Dimension is required';
+ } else if (isNaN(vector.dimension)) {
+ error.dimension = 'Dimension must be a number';
+ }
+ if (!vector.distance) {
+ error.distance = 'Distance is required';
+ }
+ if (vectors.length > 1 && !vector.name) {
+ error.name = 'Vector name is required';
+ }
+ } else {
+ if (!vector.name) {
+ error.name = 'Vector name is required';
+ }
+ }
+ return error;
+ });
+ setErrors(newErrors);
+ return newErrors.every((error) => !Object.values(error).length);
+ };
+
+ const handleContinue = () => {
+ if (validateAll()) {
+ handleNext();
+ }
+ };
+
+ return (
+
+
+ {vectors.map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+VectorConfig.propTypes = {
+ handleNext: PropTypes.func.isRequired,
+ handleBack: PropTypes.func.isRequired,
+ vectors: PropTypes.array.isRequired,
+ setVectors: PropTypes.func.isRequired,
+};
+
+export default VectorConfig;
diff --git a/src/components/CreateCollection/index.jsx b/src/components/CreateCollection/index.jsx
new file mode 100644
index 00000000..ed4446ef
--- /dev/null
+++ b/src/components/CreateCollection/index.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useTheme } from '@mui/material/styles';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import Box from '@mui/material/Box';
+import Tooltip from '@mui/material/Tooltip';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import { Add } from '@mui/icons-material';
+import CreateCollectionForm from './CreateCollectionForm';
+import { Alert } from '@mui/material';
+import { Link } from 'react-router-dom';
+
+const CreateCollection = ({ onComplete, collections, sx }) => {
+ const theme = useTheme();
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+ const [open, setOpen] = React.useState(false);
+ const handleCreateClick = () => {
+ setOpen(true);
+ };
+
+ const handleCreated = () => {
+ setTimeout(() => {
+ setOpen(false);
+ }, 1000);
+ };
+
+ return (
+
+
+ }>
+ Create Collection
+
+
+
+
+ );
+};
+
+// props validation
+CreateCollection.propTypes = {
+ onComplete: PropTypes.func,
+ collections: PropTypes.array,
+ sx: PropTypes.object,
+};
+
+export default CreateCollection;
diff --git a/src/pages/Collections.jsx b/src/pages/Collections.jsx
index cb6f42b3..efe96764 100644
--- a/src/pages/Collections.jsx
+++ b/src/pages/Collections.jsx
@@ -8,6 +8,7 @@ import { SnapshotsUpload } from '../components/Snapshots/SnapshotsUpload';
import { getErrorMessage } from '../lib/get-error-message';
import CollectionsList from '../components/Collections/CollectionsList';
import { debounce } from 'lodash';
+import CreateCollection from '../components/CreateCollection/Index';
function Collections() {
const [rawCollections, setRawCollections] = useState(null);
@@ -114,8 +115,13 @@ function Collections() {
Collections
-
+
getCollectionsCall(currentPage)} key={'snapshots'} />
+ getCollectionsCall(currentPage)}
+ key={'create-collection'}
+ collections={collections}
+ />