diff --git a/featureflags/__init__.py b/featureflags/__init__.py index 72f26f5..0b2f79d 100644 --- a/featureflags/__init__.py +++ b/featureflags/__init__.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.1.3" diff --git a/featureflags/graph/actions.py b/featureflags/graph/actions.py index 8824ca2..0370db1 100644 --- a/featureflags/graph/actions.py +++ b/featureflags/graph/actions.py @@ -489,13 +489,43 @@ async def update_value_changelog( @auth_required @track async def delete_variable( - variable_id: str, *, conn: SAConnection, + variable_id: str, + *, + conn: SAConnection, ) -> None: assert variable_id, "Variable id is required" variable_uuid = UUID(hex=variable_id) await conn.execute( - Variable.__table__.delete().where( - Variable.id == variable_uuid + Variable.__table__.delete().where(Variable.id == variable_uuid) + ) + + +@auth_required +@track +async def delete_project( + project_id: str, + *, + conn: SAConnection, +) -> None: + assert project_id, "Project id is required" + + project_uuid = UUID(hex=project_id) + + variables = await conn.execute( + select([Variable.id]).where(Project.id == project_uuid) + ) + variable_ids = [v.id for v in await variables.fetchall()] + + if variable_ids: + await conn.execute( + Check.__table__.delete().where(Check.variable.in_(variable_ids)) ) + + await conn.execute( + Variable.__table__.delete().where(Variable.project == project_uuid) + ) + + await conn.execute( + Project.__table__.delete().where(Project.id == project_uuid) ) diff --git a/featureflags/graph/graph.py b/featureflags/graph/graph.py index 1d3bade..8cbdbb2 100644 --- a/featureflags/graph/graph.py +++ b/featureflags/graph/graph.py @@ -54,6 +54,7 @@ SaveFlagResult, SaveValueResult, DeleteVariableResult, + DeleteProjectResult, ) from featureflags.graph.utils import is_valid_uuid from featureflags.metrics import wrap_metric @@ -170,9 +171,7 @@ async def root_values(ctx: dict, options: dict) -> list: ) if value_name: - expr = ( - expr.where(Value.name.ilike(f"%{value_name}%")) - ) + expr = expr.where(Value.name.ilike(f"%{value_name}%")) return await exec_expression(ctx[GraphContext.DB_ENGINE], expr) @@ -867,6 +866,28 @@ def get_field(name: str) -> str | None: ], ) + +async def delete_project_info( + fields: list[Field], results: list[DeleteProjectResult] +) -> list[list]: + [result] = results + + def get_field(name: str) -> str | None: + if name == "error": + return result.error + + raise ValueError(f"Unknown field: {name}") + + return [[get_field(f.name)] for f in fields] + + +DeleteProjectNode = Node( + "DeleteProject", + [ + Field("error", None, delete_project_info), + ], +) + GRAPH = Graph( [ ProjectNode, @@ -1161,6 +1182,36 @@ async def delete_variable(ctx: dict, options: dict) -> DeleteVariableResult: return DeleteVariableResult(None) +@pass_context +async def delete_project(ctx: dict, options: dict) -> DeleteProjectResult: + async with ctx[GraphContext.DB_ENGINE].acquire() as conn: + project_uuid = UUID(options["id"]) + + is_flags_exists = await exec_scalar( + ctx[GraphContext.DB_ENGINE], + (select([Flag.id]).where(Flag.project == project_uuid).limit(1)), + ) + if is_flags_exists: + return DeleteProjectResult("You need delete all Flags firstly.") + + is_values_exists = await exec_scalar( + ctx[GraphContext.DB_ENGINE], + (select([Value.id]).where(Value.project == project_uuid).limit(1)), + ) + if is_values_exists: + return DeleteProjectResult("You need delete all Values firstly.") + + try: + await actions.delete_project( + options["id"], + conn=conn, + ) + except Exception as e: + return DeleteProjectResult(str(e)) + + return DeleteProjectResult(None) + + mutation_data_types = { "SaveFlagOperation": Record[{"type": String, "payload": Any}], "SaveValueOperation": Record[{"type": String, "payload": Any}], @@ -1178,6 +1229,7 @@ async def delete_variable(ctx: dict, options: dict) -> DeleteVariableResult: DeleteFlagNode, DeleteValueNode, DeleteVariableNode, + DeleteProjectNode, Root( [ Link( @@ -1249,6 +1301,13 @@ async def delete_variable(ctx: dict, options: dict) -> DeleteVariableResult: options=[Option("id", String)], requires=None, ), + Link( + "deleteProject", + TypeRef["DeleteProject"], + delete_project, + options=[Option("id", String)], + requires=None, + ), ] ), ], diff --git a/featureflags/graph/types.py b/featureflags/graph/types.py index 51c50d8..534c9c1 100644 --- a/featureflags/graph/types.py +++ b/featureflags/graph/types.py @@ -206,3 +206,7 @@ class DeleteValueResult(NamedTuple): class DeleteVariableResult(NamedTuple): error: str | None = None + + +class DeleteProjectResult(NamedTuple): + error: str | None = None diff --git a/ui/src/Base.jsx b/ui/src/Base.jsx index 46cf8e7..5bd1666 100644 --- a/ui/src/Base.jsx +++ b/ui/src/Base.jsx @@ -23,12 +23,7 @@ function Base({ children }) { const navigate = useNavigate(); const queryParams = new URLSearchParams(location.search); - const tab = queryParams.get('tab') || "flags"; - - const setTabToUrl = (name) => { - queryParams.set('tab', name); - navigate(`/?${queryParams.toString()}`); - } + const tab = queryParams.get('tab') === 'values' ? 'values' : 'flags'; const handleSearchTermChange = (e) => { const value = e.target.value; @@ -43,6 +38,7 @@ function Base({ children }) { const setSearchTermToUrl = (e) => { const value = e.target.value; queryParams.set('term', value); + queryParams.set('tab', tab); navigate(`/?${queryParams.toString()}`); }; @@ -79,10 +75,9 @@ function Base({ children }) { > FeatureFlags - - + {tab && ( )} - - - + {auth.authenticated && + ]; + + return ( +
+ +
+ Project `{projectName}` settings + ( + + {item} + + )} + /> +
+
+ ); +} diff --git a/ui/src/Dashboard/Tabs.jsx b/ui/src/Dashboard/Tabs.jsx new file mode 100644 index 0000000..ae73d40 --- /dev/null +++ b/ui/src/Dashboard/Tabs.jsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Tabs } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import { useSearchParams } from 'react-router-dom'; +import './Tabs.less'; + +const { TabPane } = Tabs; + +const HeaderTabs = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const tab = searchParams.get('tab') || 'flags'; + const project = searchParams.get('project'); + const searchTerm = searchParams.get('term'); + + const onTabChange = (key) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('tab', key); + setSearchParams(newParams); + }; + + return ( + + Flags} key="flags" /> + Values} key="values" /> + {project && !searchTerm && ( + Settings} key="settings"/> + )} + + ); +} + +export { HeaderTabs }; diff --git a/ui/src/Dashboard/Tabs.less b/ui/src/Dashboard/Tabs.less new file mode 100644 index 0000000..f45647c --- /dev/null +++ b/ui/src/Dashboard/Tabs.less @@ -0,0 +1,30 @@ +.custom-tabs .ant-tabs-nav .ant-tabs-tab { + background-color: #1890ff; + color: white; + padding: 8px 16px; + border-radius: 4px; + margin-right: 8px; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.custom-tabs .ant-tabs-nav .ant-tabs-tab-active { + background-color: #c9c0c0; + color: white; +} + +.custom-tabs .ant-tabs-nav .ant-tabs-tab:hover { + background-color: #c9c0c0; +} + +.custom-tabs .ant-tabs-nav .ant-tabs-tab-active:hover { + background-color: #1890ff; +} + +.custom-tabs .ant-tabs-nav .ant-tabs-tab + .ant-tabs-tab { + margin-left: 0px; +} + +.custom-tabs .ant-tabs-ink-bar { + display: none !important; +} diff --git a/ui/src/Dashboard/Values.jsx b/ui/src/Dashboard/Values.jsx index 146c147..b391832 100644 --- a/ui/src/Dashboard/Values.jsx +++ b/ui/src/Dashboard/Values.jsx @@ -16,12 +16,17 @@ import { CenteredSpinner } from '../components/Spinner'; import { ProjectsMapContext } from './context'; import { VALUES_QUERY } from './queries'; import { Value } from './Value'; +import { HeaderTabs } from "./Tabs"; const getShowAllMatches = (count, searchText) => ({ label: `Show all matches(${count})`, value: searchText }); +function getValueKey(value) { + return `${value.name}_${value.project.name}` +} + const Values = ({ values, isSearch }) => { const location = useLocation(); const navigate = useNavigate(); @@ -49,12 +54,15 @@ const Values = ({ values, isSearch }) => { }, [valueFromQuery]); const valuesMap = useMemo(() => values.reduce((acc, value) => { - acc[value.name] = value; + acc[getValueKey(value)] = value; return acc; }, {}), [values]); if (!values.length) { - return
No values
; + return
+ + No values +
; } const listData = values @@ -64,7 +72,7 @@ const Values = ({ values, isSearch }) => { .map((value) => { return { title: value.name, - key: value.name, + key: getValueKey(value), }; }); @@ -108,6 +116,7 @@ const Values = ({ values, isSearch }) => { padding: '0 16px', }} > + {!isSearch && (