diff --git a/featureflags/graph/graph.py b/featureflags/graph/graph.py index 24fbb1f..29f84f1 100644 --- a/featureflags/graph/graph.py +++ b/featureflags/graph/graph.py @@ -105,6 +105,7 @@ async def root_flags(ctx: dict, options: dict) -> list: return [] project_name = options.get("project_name") + flag_name = options.get("flag_name") expr = select([Flag.id]) if project_name is not None: @@ -114,6 +115,9 @@ async def root_flags(ctx: dict, options: dict) -> list: ) ) + if flag_name: + expr = expr.where(Flag.name.ilike(f"%{flag_name}%")) + return await exec_expression(ctx[GraphContext.DB_ENGINE], expr) @@ -154,6 +158,7 @@ async def root_values(ctx: dict, options: dict) -> list: return [] project_name = options.get("project_name") + value_name = options.get("value_name") expr = select([Value.id]) if project_name is not None: @@ -163,6 +168,11 @@ async def root_values(ctx: dict, options: dict) -> list: ) ) + if value_name: + expr = ( + expr.where(Value.name.ilike(f"%{value_name}%")) + ) + return await exec_expression(ctx[GraphContext.DB_ENGINE], expr) @@ -616,7 +626,10 @@ async def get_value_last_action_timestamp( Sequence["Flag"], root_flags, requires=None, - options=[Option("project_name", Optional[String], default=None)], + options=[ + Option("project_name", Optional[String], default=None), + Option("flag_name", Optional[String], default=None), + ], ), Link( "flags_by_ids", @@ -637,7 +650,10 @@ async def get_value_last_action_timestamp( Sequence["Value"], root_values, requires=None, - options=[Option("project_name", Optional[String], default=None)], + options=[ + Option("project_name", Optional[String], default=None), + Option("value_name", Optional[String], default=None), + ], ), Link( "values_by_ids", diff --git a/featureflags/migrations/versions/a327a3ea7a5f_added_flags_and_values_name_idx.py b/featureflags/migrations/versions/a327a3ea7a5f_added_flags_and_values_name_idx.py new file mode 100644 index 0000000..1e61c7b --- /dev/null +++ b/featureflags/migrations/versions/a327a3ea7a5f_added_flags_and_values_name_idx.py @@ -0,0 +1,23 @@ +import sqlalchemy as sa + +from alembic import op + + + +revision = 'a327a3ea7a5f' +down_revision = '4d42cf3d11de' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('flag_name_idx', 'flag', ['name'], unique=False) + op.create_index('value_name_idx', 'value', ['name'], unique=False) + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('value_name_idx', table_name='value') + op.drop_index('flag_name_idx', table_name='flag') + # ### end Alembic commands ### diff --git a/featureflags/models.py b/featureflags/models.py index 221038c..6d05e26 100644 --- a/featureflags/models.py +++ b/featureflags/models.py @@ -134,6 +134,7 @@ class Flag(Base): __table_args__ = ( UniqueConstraint(project, name), Index("flag_project_name_idx", project, name), + Index("flag_name_idx", name), ) @@ -213,6 +214,7 @@ class Value(Base): __table_args__ = ( UniqueConstraint(project, name), Index("value_project_name_idx", project, name), + Index("value_name_idx", name), ) diff --git a/ui/src/Base.jsx b/ui/src/Base.jsx index d55995a..46cf8e7 100644 --- a/ui/src/Base.jsx +++ b/ui/src/Base.jsx @@ -1,5 +1,6 @@ +import { useEffect, useRef, useState } from 'react' import { Navigate, useLocation, useNavigate } from 'react-router-dom'; -import { Layout, Typography, Space, Button, Row, Col } from 'antd'; +import { Layout, Typography, Space, Button, Row, Col, Input } from 'antd'; const { Header } = Layout; const { Link } = Typography; @@ -7,12 +8,14 @@ import { Logo } from './components/Logo'; import './Base.less'; import { useAuth, useSignOut } from './hooks'; import { CenteredSpinner } from './components/Spinner'; +import { SearchOutlined } from "@ant-design/icons"; function Base({ children }) { const location = useLocation(); const {auth, loading} = useAuth(); const [signOut] = useSignOut(); + const [inputValue, setInputValue] = useState(''); if (!loading && !auth.authenticated && location.pathname !== '/sign-in') { return @@ -20,12 +23,44 @@ 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 handleSearchTermChange = (e) => { + const value = e.target.value; + setInputValue(value); + + if (value === '') { + queryParams.delete('term'); + navigate(`/?${queryParams.toString()}`); + } + }; + + const setSearchTermToUrl = (e) => { + const value = e.target.value; + queryParams.set('term', value); + navigate(`/?${queryParams.toString()}`); + }; + + const inputRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === '/') { + event.preventDefault(); + inputRef.current.focus(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + return ( - + + {tab && ( + } + value={inputValue || queryParams.get('term')} + size="middle" + allowClear + placeholder={`${tab} global search`} + onChange={handleSearchTermChange} + onPressEnter={setSearchTermToUrl} + /> + )} + + + + OK + , + ]} + > + + + + - - - OK - , - ]} - > - - - - ) } @@ -188,6 +192,7 @@ const getInitialFlagState = (flag) => ({ conditions: flag.conditions.map((c) => c.id), createdTimestamp: flag.created_timestamp, reportedTimestamp: flag.reported_timestamp, + projectName: flag.project.name, }); const getInitialConditions = (flag) => { @@ -224,8 +229,9 @@ const getInitialChecks = (flag, project) => { }; -export const Flag = ({ flag }) => { - const project = useProject(); +export const Flag = ({ flag, isSearch }) => { + const projectsMap = useProjectsMap(); + const project = projectsMap[flag.project.name]; const [flagState, setFlagState] = useState(getInitialFlagState(flag)); const [conditions, setConditions] = useState(getInitialConditions(flag)); const [checks, setChecks] = useState(getInitialChecks(flag, project)); @@ -461,10 +467,12 @@ export const Flag = ({ flag }) => { size="small" className={saveFlagFailed ? 'invalid' : ''} title={} style={{ width: 800, borderRadius: '5px' }} > diff --git a/ui/src/Dashboard/Flags.jsx b/ui/src/Dashboard/Flags.jsx index 6a548c5..2ef9ae5 100644 --- a/ui/src/Dashboard/Flags.jsx +++ b/ui/src/Dashboard/Flags.jsx @@ -13,7 +13,7 @@ import { useQuery } from '@apollo/client'; import './Flags.less'; import { CenteredSpinner } from '../components/Spinner'; -import { ProjectContext } from './context'; +import { ProjectsMapContext } from './context'; import { FLAGS_QUERY } from './queries'; import { Flag } from './Flag'; @@ -22,7 +22,7 @@ const getShowAllMatches = (count, searchText) => ({ value: searchText }); -const Flags = ({ flags }) => { +const Flags = ({ flags, isSearch }) => { const location = useLocation(); const navigate = useNavigate(); const queryParams = new URLSearchParams(location.search); @@ -108,28 +108,30 @@ const Flags = ({ flags }) => { padding: '0 16px', }} > - - } - size="middle" - allowClear - placeholder="Filter flags" - /> - + {!isSearch && ( + + } + size="middle" + allowClear + placeholder="Filter flags" + /> + + )} ( - + )} /> @@ -138,24 +140,32 @@ const Flags = ({ flags }) => { }; -export const FlagsContainer = ({ project }) => { +export const FlagsContainer = ({ projectName, searchTerm, projectsMap }) => { const { data, loading, error, networkStatus } = useQuery(FLAGS_QUERY, { - variables: { project: project.name }, + variables: { + project: searchTerm ? null : projectName, + flag_name: searchTerm, + }, }); if (loading) { return ; } - const _project = { - ...project, - variablesMap: project.variables.reduce((acc, variable) => { - acc[variable.id] = variable; - return acc; - }, {}), - } + const _projectsMap = Object.keys(projectsMap).reduce((acc, key) => { + const _project = projectsMap[key]; + acc[key] = { + ..._project, + variablesMap: _project.variables.reduce((variableAcc, variable) => { + variableAcc[variable.id] = variable; + return variableAcc; + }, {}), + }; + return acc; + }, {}); + return ( - - - + + + ); } diff --git a/ui/src/Dashboard/Value.jsx b/ui/src/Dashboard/Value.jsx index 919c111..e30c0b3 100644 --- a/ui/src/Dashboard/Value.jsx +++ b/ui/src/Dashboard/Value.jsx @@ -17,6 +17,7 @@ import { message, Input, Modal, + Tag, } from 'antd'; import { useEffect, useState } from 'react'; import { @@ -28,7 +29,7 @@ import './Value.less'; import { ValueContext, useValueState, - useProject, + useProjectsMap, } from './context'; import { ValueConditions } from './ValueConditions'; import { TYPES, KIND_TO_TYPE, KIND, TYPE_TO_KIND } from './constants'; @@ -121,7 +122,7 @@ const Buttons = ({ onReset, onCancel, onSave, onDelete, onToggle, onValueOverrid ); } -const ValueTitle = ({ name, valueId, createdTimestamp, reportedTimestamp }) => { +const ValueTitle = ({ isSearch, projectName, name, valueId, createdTimestamp, reportedTimestamp }) => { const [ isModalVisible, setIsModalVisible ] = useState(false); const [ valueHistory, setValueHistory ] = useState({ lastAction: 'Loading...', @@ -159,34 +160,37 @@ const ValueTitle = ({ name, valueId, createdTimestamp, reportedTimestamp }) => { ); return ( -
-
- - - {name} - +
+ {isSearch ? {projectName} : null } +
+
+ + + {name} + +
+ + + OK + , + ]} + > + + + +
- - - OK - , - ]} - > - - - -
) } @@ -203,6 +207,7 @@ const getInitialValueState = (value) => ({ conditions: value.conditions.map((c) => c.id), createdTimestamp: value.created_timestamp, reportedTimestamp: value.reported_timestamp, + projectName: value.project.name, }); const getInitialConditions = (value) => { @@ -240,8 +245,9 @@ const getInitialChecks = (value, project) => { }; -export const Value = ({ value }) => { - const project = useProject(); +export const Value = ({ value, isSearch }) => { + const projectsMap = useProjectsMap(); + const project = projectsMap[value.project.name]; const [valueState, setValueState] = useState(getInitialValueState(value)); const [conditions, setConditions] = useState(getInitialConditions(value)); const [checks, setChecks] = useState(getInitialChecks(value, project)); @@ -488,6 +494,8 @@ export const Value = ({ value }) => { size="small" className={saveValueFailed ? 'invalid' : ''} title={ { - const project = useProject(); +const CheckInput = ({ conditionId, check, projectName }) => { + const projectsMap = useProjectsMap(); + const project = projectsMap[projectName]; const { setValueString, setValueNumber, @@ -81,8 +82,9 @@ const CheckInput = ({ conditionId, check }) => { />; } -export const ValueCheck = ({ conditionId, check, onDeleteCheck, conditionValueOverride, onValueConditionOverrideChange }) => { - const project = useProject(); +export const ValueCheck = ({ conditionId, check, onDeleteCheck, conditionValueOverride, onValueConditionOverrideChange, projectName }) => { + const projectsMap = useProjectsMap(); + const project = projectsMap[projectName]; const { setVariable, setOperator } = useValueChecks(); const onVariableOptionChange = (value) => { @@ -130,7 +132,7 @@ export const ValueCheck = ({ conditionId, check, onDeleteCheck, conditionValueOv ))} - + { +const ValueCondition = ({ onRemove, condition, onValueConditionOverrideChange, projectName }) => { const { addCheck, deleteCheck } = useValueCtx(); const { checks } = useValueChecks(); @@ -34,6 +34,7 @@ const ValueCondition = ({ onRemove, condition, onValueConditionOverrideChange }) onDeleteCheck={() => deleteCheck(condition.id, checkId)} conditionValueOverride={condition.value_override} onValueConditionOverrideChange={onValueConditionOverrideChange} + projectName={projectName} /> ) })} @@ -72,6 +73,7 @@ export const ValueConditions = () => { condition={conditions[conditionId]} onRemove={() => deleteCondition(conditions[conditionId])} onValueConditionOverrideChange={(e) => updateValueConditionOverride(conditionId, e.target.value)} + projectName={value.projectName} /> ) })} diff --git a/ui/src/Dashboard/Values.jsx b/ui/src/Dashboard/Values.jsx index 5ca6d35..146c147 100644 --- a/ui/src/Dashboard/Values.jsx +++ b/ui/src/Dashboard/Values.jsx @@ -13,7 +13,7 @@ import { useQuery } from '@apollo/client'; import './Values.less'; import { CenteredSpinner } from '../components/Spinner'; -import { ProjectContext } from './context'; +import { ProjectsMapContext } from './context'; import { VALUES_QUERY } from './queries'; import { Value } from './Value'; @@ -22,7 +22,7 @@ const getShowAllMatches = (count, searchText) => ({ value: searchText }); -const Values = ({ values }) => { +const Values = ({ values, isSearch }) => { const location = useLocation(); const navigate = useNavigate(); const queryParams = new URLSearchParams(location.search); @@ -108,28 +108,30 @@ const Values = ({ values }) => { padding: '0 16px', }} > - - } - size="middle" - allowClear - placeholder="Filter values" - /> - + {!isSearch && ( + + } + size="middle" + allowClear + placeholder="Filter values" + /> + + )} ( - + )} /> @@ -138,24 +140,32 @@ const Values = ({ values }) => { }; -export const ValuesContainer = ({ project }) => { +export const ValuesContainer = ({ projectName, searchTerm, projectsMap }) => { const { data, loading, error, networkStatus } = useQuery(VALUES_QUERY, { - variables: { project: project.name }, + variables: { + project: searchTerm ? null : projectName, + value_name: searchTerm, + }, }); if (loading) { return ; } - const _project = { - ...project, - variablesMap: project.variables.reduce((acc, variable) => { - acc[variable.id] = variable; - return acc; - }, {}), - } + const _projectsMap = Object.keys(projectsMap).reduce((acc, key) => { + const _project = projectsMap[key]; + acc[key] = { + ..._project, + variablesMap: _project.variables.reduce((variableAcc, variable) => { + variableAcc[variable.id] = variable; + return variableAcc; + }, {}), + }; + return acc; + }, {}); + return ( - - - + + + ); } diff --git a/ui/src/Dashboard/context.jsx b/ui/src/Dashboard/context.jsx index f9e62ac..a089d49 100644 --- a/ui/src/Dashboard/context.jsx +++ b/ui/src/Dashboard/context.jsx @@ -1,9 +1,9 @@ import { createContext, useContext } from 'react'; -export const ProjectContext = createContext([[]]); +export const ProjectsMapContext = createContext([[]]); -export const useProject = () => { - const [project] = useContext(ProjectContext); +export const useProjectsMap = () => { + const [project] = useContext(ProjectsMapContext); return project; } diff --git a/ui/src/Dashboard/queries.js b/ui/src/Dashboard/queries.js index 6e6c9fc..dbaa9cf 100644 --- a/ui/src/Dashboard/queries.js +++ b/ui/src/Dashboard/queries.js @@ -22,6 +22,9 @@ const FLAG_FRAGMENT = gql` overridden created_timestamp reported_timestamp + project { + name + } conditions { id checks { @@ -78,8 +81,8 @@ export const FLAG_QUERY = gql` export const FLAGS_QUERY = gql` ${FLAG_FRAGMENT} - query Flags($project: String!) { - flags(project_name: $project) { + query Flags($project: String, $flag_name: String) { + flags(project_name: $project, flag_name: $flag_name) { ...FlagFragment } } @@ -101,6 +104,9 @@ const VALUE_FRAGMENT = gql` value_override created_timestamp reported_timestamp + project { + name + } conditions { id value_override @@ -157,8 +163,8 @@ export const VALUE_QUERY = gql` export const VALUES_QUERY = gql` ${VALUE_FRAGMENT} - query Values($project: String!) { - values(project_name: $project) { + query Values($project: String, $value_name: String) { + values(project_name: $project, value_name: $value_name) { ...ValueFragment } }