From 6e8e29596d4afe38560b42ecc62939611ed65907 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:19:00 +0200 Subject: [PATCH 1/9] chore: increased selector usage Signed-off-by: Manuel Zedel --- .../deployments/createdeployment.js | 3 ++- src/js/components/releases/releasedetails.js | 4 ++-- src/js/components/releases/releases.js | 22 +++++++++++-------- src/js/components/releases/releaseslist.js | 10 ++++----- src/js/selectors/index.js | 8 +++++++ 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/js/components/deployments/createdeployment.js b/src/js/components/deployments/createdeployment.js index 4c558fab5d..a40a116e52 100644 --- a/src/js/components/deployments/createdeployment.js +++ b/src/js/components/deployments/createdeployment.js @@ -54,6 +54,7 @@ import { getIdAttribute, getIsEnterprise, getOnboardingState, + getReleaseListState, getReleasesById, getTenantCapabilities } from '../../selectors'; @@ -124,7 +125,7 @@ export const CreateDeployment = props => { const { needsDeploymentConfirmation: needsCheck, previousPhases = [], retries: previousRetries = 0 } = useSelector(getGlobalSettings); const onboardingState = useSelector(getOnboardingState) || {}; const { complete: isOnboardingComplete } = onboardingState; - const releases = useSelector(state => state.releases.releasesList.searchedIds); + const { searchedIds: releases } = useSelector(getReleaseListState); const releasesById = useSelector(getReleasesById); const groupNames = useSelector(getGroupNames); const dispatch = useDispatch(); diff --git a/src/js/components/releases/releasedetails.js b/src/js/components/releases/releasedetails.js index aee4b479f9..a5ecbacd6e 100644 --- a/src/js/components/releases/releasedetails.js +++ b/src/js/components/releases/releasedetails.js @@ -34,7 +34,7 @@ import { setSnackbar } from '../../actions/appActions'; import { removeArtifact, removeRelease, selectArtifact, selectRelease } from '../../actions/releaseActions'; import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; import { FileSize, customSort, formatTime, toggle } from '../../helpers'; -import { getFeatures, getUserCapabilities } from '../../selectors'; +import { getFeatures, getSelectedRelease, getUserCapabilities } from '../../selectors'; import useWindowSize from '../../utils/resizehook'; import ChipSelect from '../common/chipselect'; import { RelativeTime } from '../common/time'; @@ -272,7 +272,7 @@ export const ReleaseDetails = () => { const navigate = useNavigate(); const dispatch = useDispatch(); const { hasReleaseTags } = useSelector(getFeatures); - const release = useSelector(state => state.releases.byId[state.releases.selectedRelease]) ?? {}; + const release = useSelector(getSelectedRelease); const selectedArtifact = useSelector(state => state.releases.selectedArtifact); const userCapabilities = useSelector(getUserCapabilities); diff --git a/src/js/components/releases/releases.js b/src/js/components/releases/releases.js index bad7264ab2..ea01acb245 100644 --- a/src/js/components/releases/releases.js +++ b/src/js/components/releases/releases.js @@ -22,7 +22,14 @@ import pluralize from 'pluralize'; import { getReleases, selectRelease, setReleasesListState } from '../../actions/releaseActions'; import { BENEFITS, SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants'; -import { getFeatures, getIsEnterprise, getReleasesList, getUserCapabilities } from '../../selectors'; +import { + getHasReleases, + getIsEnterprise, + getReleaseListState, + getReleasesList, + getSelectedRelease, + getUserCapabilities, +} from '../../selectors'; import { useDebounce } from '../../utils/debouncehook'; import { useLocationParams } from '../../utils/liststatehook'; import ChipSelect from '../common/chipselect'; @@ -67,6 +74,7 @@ const Header = ({ canUpload, existingTags = [], features, hasReleases, releasesL const { hasReleaseTags } = features; const { selectedTags = [], searchTerm, searchTotal, tab = tabs[0].key, total } = releasesListState; const { classes } = useStyles(); + const hasReleases = useSelector(getHasReleases); const searchUpdated = useCallback(searchTerm => setReleasesListState({ searchTerm }), [setReleasesListState]); @@ -113,22 +121,18 @@ const Header = ({ canUpload, existingTags = [], features, hasReleases, releasesL export const Releases = () => { const features = useSelector(getFeatures); - const hasReleases = useSelector( - state => !!(Object.keys(state.releases.byId).length || state.releases.releasesList.total || state.releases.releasesList.searchTotal) - ); + const releasesListState = useSelector(getReleaseListState); + const { searchTerm, sort = {}, page, perPage, tab = tabs[0].key, selectedTags } = releasesListState; const releases = useSelector(getReleasesList); - const releasesListState = useSelector(state => state.releases.releasesList); const releaseTags = useSelector(state => state.releases.releaseTags); - const selectedRelease = useSelector(state => state.releases.byId[state.releases.selectedRelease]) ?? {}; - const userCapabilities = useSelector(getUserCapabilities); - const { canUploadReleases } = userCapabilities; + const selectedRelease = useSelector(getSelectedRelease); + const { canUploadReleases } = useSelector(getUserCapabilities); const dispatch = useDispatch(); const [selectedFile, setSelectedFile] = useState(); const [showAddArtifactDialog, setShowAddArtifactDialog] = useState(false); const artifactTimer = useRef(); const [locationParams, setLocationParams] = useLocationParams('releases', { defaults: { direction: SORTING_OPTIONS.desc, key: 'modified' } }); - const { searchTerm, sort = {}, page, perPage, tab = tabs[0].key, selectedTags } = releasesListState; const debouncedSearchTerm = useDebounce(searchTerm, TIMEOUTS.debounceDefault); useEffect(() => { diff --git a/src/js/components/releases/releaseslist.js b/src/js/components/releases/releaseslist.js index d2231ea27b..277079ac0c 100644 --- a/src/js/components/releases/releaseslist.js +++ b/src/js/components/releases/releaseslist.js @@ -21,7 +21,7 @@ import { setSnackbar } from '../../actions/appActions'; import { selectRelease, setReleasesListState } from '../../actions/releaseActions'; import { SORTING_OPTIONS, canAccess as canShow } from '../../constants/appConstants'; import { DEVICE_LIST_DEFAULTS } from '../../constants/deviceConstants'; -import { getFeatures, getReleasesList, getUserCapabilities } from '../../selectors'; +import { getFeatures, getHasReleases, getReleaseListState, getReleasesList, getUserCapabilities } from '../../selectors'; import DetailsTable from '../common/detailstable'; import Loader from '../common/loader'; import Pagination from '../common/pagination'; @@ -89,18 +89,16 @@ export const ReleasesList = ({ onFileUploadClick }) => { const repoRef = useRef(); const dropzoneRef = useRef(); const uploading = useSelector(state => state.app.uploading); - const hasReleases = useSelector( - state => !!(Object.keys(state.releases.byId).length || state.releases.releasesList.total || state.releases.releasesList.searchTotal) - ); + const releasesListState = useSelector(getReleaseListState); + const { isLoading, page = defaultPage, perPage = defaultPerPage, searchTerm, sort = {}, searchTotal, tags = [], total } = releasesListState; + const hasReleases = useSelector(getHasReleases); const features = useSelector(getFeatures); const releases = useSelector(getReleasesList); - const releasesListState = useSelector(state => state.releases.releasesList); const userCapabilities = useSelector(getUserCapabilities); const dispatch = useDispatch(); const { classes } = useStyles(); const { canUploadReleases } = userCapabilities; - const { isLoading, page = defaultPage, perPage = defaultPerPage, searchTerm, sort = {}, searchTotal, total } = releasesListState; const { key: attribute, direction } = sort; const onSelect = useCallback(id => dispatch(selectRelease(id)), [dispatch]); diff --git a/src/js/selectors/index.js b/src/js/selectors/index.js index 411d74dd03..770f79413b 100644 --- a/src/js/selectors/index.js +++ b/src/js/selectors/index.js @@ -55,8 +55,10 @@ const getDevicesList = state => Object.values(state.devices.byId); const getOnboarding = state => state.onboarding; export const getGlobalSettings = state => state.users.globalSettings; const getIssueCountsByType = state => state.monitor.issueCounts.byType; +const getSelectedReleaseId = state => state.releases.selectedRelease; export const getReleasesById = state => state.releases.byId; const getReleaseTags = state => state.releases.releaseTags; +export const getReleaseListState = state => state.releases.releasesList; const getListedReleases = state => state.releases.releasesList.releaseIds; export const getExternalIntegrations = state => state.organization.externalDeviceIntegrations; const getDeploymentsById = state => state.deployments.byId; @@ -406,6 +408,12 @@ const getReleaseMappingDefaults = () => ({}); export const getReleasesList = createSelector([getReleasesById, getListedReleases, getReleaseMappingDefaults], listItemMapper); export const getReleaseTagsById = createSelector([getReleaseTags], releaseTags => releaseTags.reduce((accu, key) => ({ ...accu, [key]: key }), {})); +export const getHasReleases = createSelector( + [getReleaseListState, getReleasesById], + ({ searchTotal, total }, byId) => !!(Object.keys(byId).length || total || searchTotal) +); + +export const getSelectedRelease = createSelector([getReleasesById, getSelectedReleaseId], (byId, id) => byId[id] ?? {}); const relevantDeploymentStates = [DEPLOYMENT_STATES.pending, DEPLOYMENT_STATES.inprogress, DEPLOYMENT_STATES.finished]; export const DEPLOYMENT_CUTOFF = 3; From 05564de4c1e37863b32fef68960b5e23edea71c2 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:24:46 +0200 Subject: [PATCH 2/9] chore: removed unused artifact selection in redux state Signed-off-by: Manuel Zedel --- src/js/actions/releaseActions.js | 17 --------------- src/js/actions/releaseActions.test.js | 12 ----------- src/js/components/releases/releasedetails.js | 22 ++++++++++---------- src/js/constants/releaseConstants.js | 1 - src/js/reducers/releaseReducer.js | 8 +------ src/js/reducers/releaseReducer.test.js | 4 ---- 6 files changed, 12 insertions(+), 52 deletions(-) diff --git a/src/js/actions/releaseActions.js b/src/js/actions/releaseActions.js index 45ce5a028d..7562a36574 100644 --- a/src/js/actions/releaseActions.js +++ b/src/js/actions/releaseActions.js @@ -251,23 +251,6 @@ export const removeArtifact = id => (dispatch, getState) => export const removeRelease = id => (dispatch, getState) => Promise.all(getState().releases.byId[id].Artifacts.map(({ id }) => dispatch(removeArtifact(id)))).then(() => dispatch(selectRelease())); -export const selectArtifact = artifact => (dispatch, getState) => { - if (!artifact) { - return dispatch({ type: ReleaseConstants.SELECTED_ARTIFACT, artifact }); - } - const artifactName = artifact.hasOwnProperty('id') ? artifact.id : artifact; - const state = getState(); - const release = Object.values(state.releases.byId).find(item => item.Artifacts.find(releaseArtifact => releaseArtifact.id === artifactName)); - if (release) { - const selectedArtifact = release.Artifacts.find(releaseArtifact => releaseArtifact.id === artifactName); - let tasks = [dispatch({ type: ReleaseConstants.SELECTED_ARTIFACT, artifact: selectedArtifact })]; - if (release.Name !== state.releases.selectedRelease) { - tasks.push(dispatch({ type: ReleaseConstants.SELECTED_RELEASE, release: release.Name })); - } - return Promise.all(tasks); - } -}; - export const selectRelease = release => dispatch => { const name = release ? release.Name || release : null; let tasks = [dispatch({ type: ReleaseConstants.SELECTED_RELEASE, release: name })]; diff --git a/src/js/actions/releaseActions.test.js b/src/js/actions/releaseActions.test.js index 131064878f..9856fef158 100644 --- a/src/js/actions/releaseActions.test.js +++ b/src/js/actions/releaseActions.test.js @@ -27,7 +27,6 @@ import { getReleases, removeArtifact, removeRelease, - selectArtifact, selectRelease, uploadArtifact } from './releaseActions'; @@ -167,17 +166,6 @@ describe('release actions', () => { expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); }); - it('should select an artifact by name', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: ReleaseConstants.SELECTED_ARTIFACT, artifact: defaultState.releases.byId.r1.Artifacts[0] }, - { type: ReleaseConstants.SELECTED_RELEASE, release: defaultState.releases.byId.r1.Name } - ]; - await store.dispatch(selectArtifact('art1')); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); it('should select a release by name', async () => { const store = mockStore({ ...defaultState }); await store.dispatch(selectRelease(defaultState.releases.byId.r1.Name)); diff --git a/src/js/components/releases/releasedetails.js b/src/js/components/releases/releasedetails.js index a5ecbacd6e..71478a93a2 100644 --- a/src/js/components/releases/releasedetails.js +++ b/src/js/components/releases/releasedetails.js @@ -199,15 +199,15 @@ const ReleaseTags = ({ existingTags = [] }) => { ); }; -const ArtifactsList = ({ artifacts, selectArtifact, selectedArtifact, setShowRemoveArtifactDialog }) => { +const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setShowRemoveArtifactDialog }) => { const [sortCol, setSortCol] = useState('modified'); const [sortDown, setSortDown] = useState(true); const onRowSelection = artifact => { - if (!artifact || !selectedArtifact || selectedArtifact.id !== artifact.id) { - return selectArtifact(artifact); + if (artifact?.id === selectedArtifact?.id) { + return setSelectedArtifact(); } - selectArtifact(); + setSelectedArtifact(artifact); }; const sortColumn = col => { @@ -241,16 +241,16 @@ const ArtifactsList = ({ artifacts, selectArtifact, selectedArtifact, setShowRem ))}
- {items.map((pkg, index) => { - const expanded = !!(selectedArtifact && selectedArtifact.id === pkg.id); + {items.map((artifact, index) => { + const expanded = !!(selectedArtifact?.id === artifact.id); return ( onRowSelection(pkg)} + onRowSelection={() => onRowSelection(artifact)} // this will be run after expansion + collapse and both need some time to fully settle // otherwise the measurements are off showRemoveArtifactDialog={setShowRemoveArtifactDialog} @@ -265,15 +265,15 @@ const ArtifactsList = ({ artifacts, selectArtifact, selectedArtifact, setShowRem export const ReleaseDetails = () => { const [showRemoveDialog, setShowRemoveArtifactDialog] = useState(false); const [confirmReleaseDeletion, setConfirmReleaseDeletion] = useState(false); + const [selectedArtifact, setSelectedArtifact] = useState(); + // eslint-disable-next-line no-unused-vars const windowSize = useWindowSize(); const creationRef = useRef(); const drawerRef = useRef(); const navigate = useNavigate(); const dispatch = useDispatch(); - const { hasReleaseTags } = useSelector(getFeatures); const release = useSelector(getSelectedRelease); - const selectedArtifact = useSelector(state => state.releases.selectedArtifact); const userCapabilities = useSelector(getUserCapabilities); const onRemoveArtifact = artifact => dispatch(removeArtifact(artifact.id)).finally(() => setShowRemoveArtifactDialog(false)); @@ -318,8 +318,8 @@ export const ReleaseDetails = () => { {hasReleaseTags && } dispatch(selectArtifact(artifact))} selectedArtifact={selectedArtifact} + setSelectedArtifact={setSelectedArtifact} setShowRemoveArtifactDialog={setShowRemoveArtifactDialog} /> { @@ -101,11 +100,6 @@ const releaseReducer = (state = initialState, action) => { selectedRelease: action.release === state.selectedRelease ? Object.keys(byId)[0] : state.selectedRelease }; } - case ReleaseConstants.SELECTED_ARTIFACT: - return { - ...state, - selectedArtifact: action.artifact - }; case ReleaseConstants.SELECTED_RELEASE: return { ...state, diff --git a/src/js/reducers/releaseReducer.test.js b/src/js/reducers/releaseReducer.test.js index f4bb8191be..c05b967008 100644 --- a/src/js/reducers/releaseReducer.test.js +++ b/src/js/reducers/releaseReducer.test.js @@ -100,10 +100,6 @@ describe('release reducer', () => { ).selectedRelease ).toEqual('test2'); }); - it('should handle SELECTED_ARTIFACT', async () => { - expect(reducer(undefined, { type: ReleaseConstants.SELECTED_ARTIFACT, artifact: testRelease.Artifacts[0] }).selectedArtifact.name).toEqual('test'); - expect(reducer(initialState, { type: ReleaseConstants.SELECTED_ARTIFACT, artifact: testRelease.Artifacts[0] }).selectedArtifact.name).toEqual('test'); - }); it('should handle SELECTED_RELEASE', async () => { expect(reducer(undefined, { type: ReleaseConstants.SELECTED_RELEASE, release: 'test' }).selectedRelease).toEqual('test'); expect(reducer(initialState, { type: ReleaseConstants.SELECTED_RELEASE, release: 'test' }).selectedRelease).toEqual('test'); From 259f58e65d9ca0413ad29881cc92033071d5e03e Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:30:41 +0200 Subject: [PATCH 3/9] chore: unified confirmation button usage to align with artifact & release info edits Signed-off-by: Manuel Zedel --- src/js/components/common/confirm.js | 21 ++++- src/js/components/common/devicenameinput.js | 31 +++---- .../devices/device-details/configuration.js | 17 +--- .../devices/device-details/devicetags.js | 8 +- src/js/components/releases/artifactdetails.js | 45 +---------- src/js/components/releases/releasedetails.js | 80 +++++++++++++++++-- src/less/main.less | 4 + 7 files changed, 116 insertions(+), 90 deletions(-) diff --git a/src/js/components/common/confirm.js b/src/js/components/common/confirm.js index 2abec3c528..b408f996af 100644 --- a/src/js/components/common/confirm.js +++ b/src/js/components/common/confirm.js @@ -13,8 +13,8 @@ // limitations under the License. import React, { useState } from 'react'; -import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon } from '@mui/icons-material'; -import { IconButton } from '@mui/material'; +import { Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Check as CheckIcon, Close as CloseIcon, Edit as EditIcon } from '@mui/icons-material'; +import { Button, IconButton } from '@mui/material'; const defaultRemoving = 'Removing...'; @@ -83,4 +83,21 @@ export const Confirm = ({ action, cancel, classes = '', message = '', style = {} ); }; +export const EditButton = ({ onClick, disabled = false }) => ( + +); + +export const ConfirmationButtons = ({ onConfirm, onCancel }) => ( +
+ + + + + + +
+); + export default Confirm; diff --git a/src/js/components/common/devicenameinput.js b/src/js/components/common/devicenameinput.js index a40feb8b5c..e9b480d315 100644 --- a/src/js/components/common/devicenameinput.js +++ b/src/js/components/common/devicenameinput.js @@ -15,11 +15,11 @@ import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; // material ui -import { Check as CheckIcon, Clear as ClearIcon, Edit as EditIcon } from '@mui/icons-material'; -import { IconButton, Input, InputAdornment } from '@mui/material'; +import { Input, InputAdornment } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import { setDeviceTags } from '../../actions/deviceActions'; +import { ConfirmationButtons, EditButton } from './confirm'; const useStyles = makeStyles()(theme => ({ icon: { @@ -59,25 +59,6 @@ export const DeviceNameInput = ({ device, isHovered }) => { setIsEditing(true); }; - const editButton = ( - - - - ); - - const buttonArea = isEditing ? ( - <> - - - - - - - - ) : ( - editButton - ); - const onInputClick = e => e.stopPropagation(); return ( @@ -90,7 +71,13 @@ export const DeviceNameInput = ({ device, isHovered }) => { onClick={onInputClick} onChange={({ target: { value } }) => setValue(value)} type="text" - endAdornment={(isHovered || isEditing) && {buttonArea}} + endAdornment={ + (isHovered || isEditing) && ( + + {isEditing ? : } + + ) + } /> ); }; diff --git a/src/js/components/devices/device-details/configuration.js b/src/js/components/devices/device-details/configuration.js index 98aecc199c..db02343849 100644 --- a/src/js/components/devices/device-details/configuration.js +++ b/src/js/components/devices/device-details/configuration.js @@ -15,14 +15,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { - Block as BlockIcon, - CheckCircle as CheckCircleIcon, - Edit as EditIcon, - Error as ErrorIcon, - Refresh as RefreshIcon, - SaveAlt as SaveAltIcon -} from '@mui/icons-material'; +import { Block as BlockIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, Refresh as RefreshIcon, SaveAlt as SaveAltIcon } from '@mui/icons-material'; import { Button, Checkbox, FormControlLabel, Typography } from '@mui/material'; import { setSnackbar } from '../../../actions/appActions'; @@ -36,7 +29,7 @@ import { deepCompare, groupDeploymentDevicesStats, groupDeploymentStats, isEmpty import { getDeviceConfigDeployment } from '../../../selectors'; import Tracking from '../../../tracking'; import ConfigurationObject from '../../common/configurationobject'; -import Confirm from '../../common/confirm'; +import Confirm, { EditButton } from '../../common/confirm'; import LogDialog from '../../common/dialogs/log'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; @@ -355,11 +348,7 @@ export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId

Device configuration

- {!(isEditingConfig || isUpdatingConfig) && ( - - )} + {!(isEditingConfig || isUpdatingConfig) && }
{isEditingConfig ? ( diff --git a/src/js/components/devices/device-details/devicetags.js b/src/js/components/devices/device-details/devicetags.js index 10e9f7d0bf..ab5bab81a7 100644 --- a/src/js/components/devices/device-details/devicetags.js +++ b/src/js/components/devices/device-details/devicetags.js @@ -14,7 +14,6 @@ import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { Edit as EditIcon } from '@mui/icons-material'; import { Button } from '@mui/material'; import { useTheme } from '@mui/material/styles'; @@ -22,6 +21,7 @@ import { setDeviceTags } from '../../../actions/deviceActions'; import { toggle } from '../../../helpers'; import Tracking from '../../../tracking'; import ConfigurationObject from '../../common/configurationobject'; +import { EditButton } from '../../common/confirm'; import KeyValueEditor from '../../common/forms/keyvalueeditor'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import DeviceDataCollapse from './devicedatacollapse'; @@ -88,11 +88,7 @@ export const DeviceTags = ({ device, setSnackbar, userCapabilities }) => {

Tags

- {!isEditing && canWriteDevices && ( - - )} + {!isEditing && canWriteDevices && }
} diff --git a/src/js/components/releases/artifactdetails.js b/src/js/components/releases/artifactdetails.js index f95f90ec11..290284cf8b 100644 --- a/src/js/components/releases/artifactdetails.js +++ b/src/js/components/releases/artifactdetails.js @@ -20,13 +20,11 @@ import { Cancel as CancelIcon, CancelOutlined as CancelOutlinedIcon, CheckCircleOutline as CheckCircleOutlineIcon, - Check as CheckIcon, - Edit as EditIcon, ExitToApp as ExitToAppIcon, Launch as LaunchIcon, Remove as RemoveIcon } from '@mui/icons-material'; -import { Accordion, AccordionDetails, AccordionSummary, Button, IconButton, Input, InputAdornment, List, ListItem, ListItemText } from '@mui/material'; +import { Accordion, AccordionDetails, AccordionSummary, Button, List, ListItem, ListItemText } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import pluralize from 'pluralize'; @@ -37,12 +35,9 @@ import { getUserCapabilities } from '../../selectors'; import ExpandableAttribute from '../common/expandable-attribute'; import ArtifactPayload from './artifactPayload'; import ArtifactMetadataList from './artifactmetadatalist'; +import { EditableLongText } from './releasedetails'; const useStyles = makeStyles()(theme => ({ - editButton: { - color: 'rgba(0, 0, 0, 0.54)', - marginBottom: 10 - }, link: { marginTop: theme.spacing() }, listItemStyle: { bordered: { @@ -126,8 +121,6 @@ const DevicesLink = ({ artifact: { installCount }, softwareItem: { key, name, ve export const ArtifactDetails = ({ artifact, open, showRemoveArtifactDialog }) => { const { classes } = useStyles(); - const [descEdit, setDescEdit] = useState(false); - const [description, setDescription] = useState(artifact.description); const [showPayloads, setShowPayloads] = useState(false); const [showProvidesDepends, setShowProvidesDepends] = useState(false); @@ -166,19 +159,7 @@ export const ArtifactDetails = ({ artifact, open, showRemoveArtifactDialog }) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [artifact.id, artifact.installCount, dispatch, open, softwareVersions.length]); - const onToggleEditing = useCallback( - event => { - event.stopPropagation(); - if (event.keyCode === 13 || !event.keyCode) { - if (descEdit) { - // save change - dispatch(editArtifact(artifact.id, { description })); - } - setDescEdit(!descEdit); - } - }, - [artifact.id, descEdit, description, dispatch] - ); + const onDescriptionChanged = useCallback(description => dispatch(editArtifact(artifact.id, { description })), [artifact.id, dispatch]); const softwareItem = extractSoftwareItem(artifact.artifact_provides); const softwareInformation = softwareItem @@ -209,25 +190,7 @@ export const ArtifactDetails = ({ artifact, open, showRemoveArtifactDialog }) => primary="Description" style={{ marginBottom: -3, minWidth: 600 }} primaryTypographyProps={{ style: { marginBottom: 3 } }} - secondary={ - setDescription(e.target.value)} - endAdornment={ - - - {descEdit ? : } - - - } - /> - } + secondary={} secondaryTypographyProps={{ component: 'div' }} /> diff --git a/src/js/components/releases/releasedetails.js b/src/js/components/releases/releasedetails.js index 71478a93a2..7ff86c0824 100644 --- a/src/js/components/releases/releasedetails.js +++ b/src/js/components/releases/releasedetails.js @@ -18,13 +18,12 @@ import { useNavigate } from 'react-router-dom'; // material ui import { Close as CloseIcon, - Edit as EditIcon, HighlightOffOutlined as HighlightOffOutlinedIcon, Link as LinkIcon, Replay as ReplayIcon, Sort as SortIcon } from '@mui/icons-material'; -import { Button, Collapse, Divider, Drawer, IconButton, SpeedDial, SpeedDialAction, SpeedDialIcon, Tooltip } from '@mui/material'; +import { Button, Collapse, Divider, Drawer, TextField, IconButton, SpeedDial, SpeedDialAction, SpeedDialIcon, Tooltip } from '@mui/material'; import { speedDialActionClasses } from '@mui/material/SpeedDialAction'; import { makeStyles } from 'tss-react/mui'; @@ -37,10 +36,14 @@ import { FileSize, customSort, formatTime, toggle } from '../../helpers'; import { getFeatures, getSelectedRelease, getUserCapabilities } from '../../selectors'; import useWindowSize from '../../utils/resizehook'; import ChipSelect from '../common/chipselect'; +import { ConfirmationButtons, EditButton } from '../common/confirm'; +import ExpandableAttribute from '../common/expandable-attribute'; import { RelativeTime } from '../common/time'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; import Artifact from './artifact'; import RemoveArtifactDialog from './dialogs/removeartifact'; +import ExpandableAttribute from '../common/expandable-attribute'; +import { ConfirmationButtons, EditButton } from '../common/confirm'; const DeviceTypeCompatibility = ({ artifact }) => { const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', '); @@ -106,7 +109,9 @@ const useStyles = makeStyles()(theme => ({ label: { marginRight: theme.spacing(2), marginBottom: theme.spacing(4) - } + }, + notes: { display: 'block', whiteSpace: 'pre-wrap' }, + notesWrapper: { minWidth: theme.components?.MuiFormControl?.styleOverrides?.root?.minWidth } })); export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease, userCapabilities }) => { @@ -149,8 +154,73 @@ export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease ); }; -const ReleaseTags = ({ existingTags = [] }) => { - const [selectedTags, setSelectedTags] = useState(existingTags); +export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => { + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(original); + const { classes } = useStyles(); + + useEffect(() => { + setValue(original); + }, [original]); + + const onCancelClick = () => { + setValue(original); + setIsEditing(false); + }; + + const onEdit = ({ target: { value } }) => setValue(value); + + const onEditClick = () => setIsEditing(true); + + const onToggleEditing = useCallback( + event => { + event.stopPropagation(); + if (event.key && (event.key !== 'Enter' || event.shiftKey)) { + return; + } + if (isEditing) { + // save change + onChange(value); + } + setIsEditing(toggle); + }, + [isEditing, onChange, value] + ); + + const fullWidthClass = fullWidth ? 'full-width' : ''; + + return ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ); +}; + const [isEditing, setIsEditing] = useState(false); const onToggleEdit = () => { diff --git a/src/less/main.less b/src/less/main.less index b93a63833a..31d7d6846f 100644 --- a/src/less/main.less +++ b/src/less/main.less @@ -539,6 +539,10 @@ ul.link-list { min-height: 30vh; } +.full-width { + width: 100% +} + .full-height, .xterm-fullscreen { height: 100%; } From 44527246daaa59be39de226d30ed75184993b116 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:37:41 +0200 Subject: [PATCH 4/9] feat: introduced unified filtering component - react-form-hook based allowing custom lists of filters that are tracked through the form lib - uses controlled mui input instances - adjusted existing uses of the filtering components Ticket: None Changelog: None Signed-off-by: Manuel Zedel --- src/js/components/common/chipselect.js | 95 +++++++++---------- .../components/common/forms/autocomplete.js | 20 ++++ src/js/components/common/forms/filters.js | 78 +++++++++++++++ src/js/components/common/search.js | 82 +++++++++------- .../artifactinformationform.test.js.snap | 59 ++++++------ .../dialogs/artifactinformationform.js | 40 ++++---- 6 files changed, 244 insertions(+), 130 deletions(-) create mode 100644 src/js/components/common/forms/autocomplete.js create mode 100644 src/js/components/common/forms/filters.js diff --git a/src/js/components/common/chipselect.js b/src/js/components/common/chipselect.js index f5395574d4..7abc932790 100644 --- a/src/js/components/common/chipselect.js +++ b/src/js/components/common/chipselect.js @@ -11,38 +11,21 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import { Autocomplete, TextField } from '@mui/material'; -import { TIMEOUTS } from '../../constants/appConstants'; import { duplicateFilter, unionizeStrings } from '../../helpers'; -import { useDebounce } from '../../utils/debouncehook'; -export const ChipSelect = ({ - className = '', - id = 'chip-select', - selection = [], - disabled = false, - inputRef, - label = '', - onChange, - options = [], - placeholder = '' -}) => { +export const ChipSelect = ({ className = '', name, disabled = false, inputRef, label = '', options = [], placeholder = '' }) => { const [value, setValue] = useState(''); - const [currentSelection, setCurrentSelection] = useState(selection); - const debouncedValue = useDebounce(value, TIMEOUTS.debounceDefault); - - useEffect(() => { - onChange({ currentValue: debouncedValue, selection: currentSelection }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedValue, JSON.stringify(currentSelection), onChange]); + const { control, getValues } = useFormContext(); // to allow device types to automatically be selected on entered ',' we have to filter the input and transform any completed device types (followed by a ',') // while also checking for duplicates and allowing complete resets of the input - const onTextInputChange = (inputValue, reason) => { + const onTextInputChange = (inputValue, reason, setCurrentSelection) => { const value = inputValue || ''; if (reason === 'clear') { setValue(''); @@ -53,43 +36,53 @@ export const ChipSelect = ({ const lastIndex = value.lastIndexOf(','); const possibleSelection = value.substring(0, lastIndex).split(',').filter(duplicateFilter); const currentValue = value.substring(lastIndex + 1); - const nextSelection = unionizeStrings(currentSelection, possibleSelection); + const selection = getValues(name); + const nextSelection = unionizeStrings(selection, possibleSelection); setValue(currentValue); setCurrentSelection(nextSelection); }; - const onTextInputLeave = value => { - const nextSelection = unionizeStrings(currentSelection, [value]); - setValue(''); + const onTextInputLeave = (value, setCurrentSelection) => { + const selection = getValues(name); + const nextSelection = unionizeStrings(selection, [value]); setCurrentSelection(nextSelection); + setValue(''); }; return ( - (e.key !== 'Backspace' ? setCurrentSelection(value) : null)} - onInputChange={(e, v, reason) => onTextInputChange(null, reason)} - options={options} - readOnly={disabled} - renderInput={params => ( - onTextInputLeave(e.target.value)} - onChange={e => onTextInputChange(e.target.value, 'input')} - placeholder={currentSelection.length ? '' : placeholder} - inputRef={inputRef} + ( + (e.key !== 'Backspace' ? formOnChange(value) : null)} + onInputChange={(e, v, reason) => onTextInputChange(null, reason, formOnChange)} + options={options} + readOnly={disabled} + ref={ref} + renderInput={params => ( + onTextInputLeave(e.target.value, formOnChange)} + onChange={e => onTextInputChange(e.target.value, 'input', formOnChange)} + placeholder={currentSelection.length ? '' : placeholder} + inputRef={inputRef} + /> + )} + {...props} /> )} /> diff --git a/src/js/components/common/forms/autocomplete.js b/src/js/components/common/forms/autocomplete.js new file mode 100644 index 0000000000..231b20f8ae --- /dev/null +++ b/src/js/components/common/forms/autocomplete.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Autocomplete } from '@mui/material'; + +// eslint-disable-next-line no-unused-vars +export const ControlledAutoComplete = ({ freeSolo, name, onChange, onInputChange, ...remainder }) => { + const { control } = useFormContext(); + + return ( + { + const onChangeHandler = (e, data) => formOnChange(data); + return ; + }} + /> + ); +}; diff --git a/src/js/components/common/forms/filters.js b/src/js/components/common/forms/filters.js new file mode 100644 index 0000000000..9c45ed4aaa --- /dev/null +++ b/src/js/components/common/forms/filters.js @@ -0,0 +1,78 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React, { useEffect, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { makeStyles } from 'tss-react/mui'; + +import { TIMEOUTS } from '../../../constants/appConstants'; +import { useDebounce } from '../../../utils/debouncehook'; + +const useStyles = makeStyles()(theme => ({ + filters: { + backgroundColor: theme.palette.background.lightgrey, + columnGap: theme.spacing(2), + display: 'flex', + flexWrap: 'wrap', + padding: `10px ${theme.spacing(3)} ${theme.spacing(3)}`, + rowGap: theme.spacing(2), + '.filter-item': { + display: 'grid' + }, + '.filter-item > div': { + alignSelf: 'end' + } + }, + filterReset: { right: theme.spacing(3) } +})); + +export const Filters = ({ className = '', defaultValues, filters = [], initialValues, onChange }) => { + const { classes } = useStyles(); + const [values, setValues] = useState(initialValues); + + const methods = useForm({ mode: 'onChange', defaultValues }); + const { formState, reset, watch, setValue } = methods; + const { isDirty } = formState; + + useEffect(() => { + Object.entries(initialValues).map(([key, value]) => setValue(key, value)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(initialValues), setValue]); + + watch(setValues); + const debouncedValues = useDebounce(values, TIMEOUTS.default); + + useEffect(() => { + onChange(debouncedValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(debouncedValues), onChange]); + + return ( + +
+ {filters.map(({ key, title, Component, componentProps }) => ( +
+
{title}
+ +
+ ))} + {isDirty && ( + reset()}> + Clear filter + + )} +
+
+ ); +}; diff --git a/src/js/components/common/search.js b/src/js/components/common/search.js index 51b2ce6305..e7a63450aa 100644 --- a/src/js/components/common/search.js +++ b/src/js/components/common/search.js @@ -11,7 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; +import { Controller, FormProvider, useForm, useFormContext } from 'react-hook-form'; import { Search as SearchIcon } from '@mui/icons-material'; import { InputAdornment, TextField } from '@mui/material'; @@ -35,12 +36,20 @@ const endAdornment = ( ); +const startAdornment = ( + + + +); + // due to search not working reliably for single letter searches, only start at 2 const MINIMUM_SEARCH_LENGTH = 2; -const Search = ({ isSearching, onSearch, placeholder = 'Search devices', searchTerm, style = {}, trigger }) => { - const [searchValue, setSearchValue] = useState(''); +export const ControlledSearch = ({ isSearching, name = 'search', onSearch, placeholder = 'Search devices', style = {} }) => { const { classes } = useStyles(); + const { control, watch } = useFormContext(); + + const searchValue = watch('search', ''); const debouncedSearchTerm = useDebounce(searchValue, TIMEOUTS.debounceDefault); @@ -51,39 +60,48 @@ const Search = ({ isSearching, onSearch, placeholder = 'Search devices', searchT onSearch(debouncedSearchTerm); }, [debouncedSearchTerm, onSearch]); - useEffect(() => { - if (!searchTerm) { - setSearchValue(searchTerm); - } - }, [searchTerm]); + const onTriggerSearch = useCallback( + ({ key }) => { + if (key === 'Enter' && (!debouncedSearchTerm || debouncedSearchTerm.length >= MINIMUM_SEARCH_LENGTH)) { + onSearch(debouncedSearchTerm); + } + }, + [debouncedSearchTerm, onSearch] + ); - const onSearchUpdated = ({ target: { value } }) => setSearchValue(value); + const adornments = isSearching ? { startAdornment, endAdornment } : { startAdornment }; + return ( + ( + + )} + /> + ); +}; - const onTriggerSearch = ({ key }) => { - if (key === 'Enter' && (!searchValue || searchValue.length >= MINIMUM_SEARCH_LENGTH)) { - onSearch(searchValue, !trigger); - } - }; +ControlledSearch.displayName = 'ConnectedSearch'; - const adornment = isSearching ? { endAdornment } : {}; +const Search = props => { + const { searchTerm, onSearch, trigger } = props; + const methods = useForm({ mode: 'onChange', defaultValues: { search: searchTerm ?? '' } }); + const { handleSubmit } = methods; return ( - - - - ), - ...adornment - }} - onChange={onSearchUpdated} - onKeyPress={onTriggerSearch} - placeholder={placeholder} - size="small" - style={style} - value={searchValue} - /> + +
onSearch(search, !trigger))}> + + + +
); }; diff --git a/src/js/components/releases/dialogs/__snapshots__/artifactinformationform.test.js.snap b/src/js/components/releases/dialogs/__snapshots__/artifactinformationform.test.js.snap index 173bd0a955..3eff50e10c 100644 --- a/src/js/components/releases/dialogs/__snapshots__/artifactinformationform.test.js.snap +++ b/src/js/components/releases/dialogs/__snapshots__/artifactinformationform.test.js.snap @@ -1016,40 +1016,45 @@ label+.emotion-16 { />
-
-
- + +
+ +
-
+ `; diff --git a/src/js/components/releases/dialogs/artifactinformationform.js b/src/js/components/releases/dialogs/artifactinformationform.js index c271a87e27..321fd862c6 100644 --- a/src/js/components/releases/dialogs/artifactinformationform.js +++ b/src/js/components/releases/dialogs/artifactinformationform.js @@ -11,7 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { FormControl, Input, InputLabel, TextField } from '@mui/material'; @@ -59,6 +60,14 @@ const checkDestinationValidity = destination => (destination.length ? /^(?:\/|[a export const ArtifactInformation = ({ creation = {}, deviceTypes = [], onRemove, updateCreation }) => { const { destination = '', file, name = '', selectedDeviceTypes = [], type } = creation; + const methods = useForm({ mode: 'onChange', defaultValues: { deviceTypes: selectedDeviceTypes } }); + const { watch } = methods; + const formDeviceTypes = watch('deviceTypes'); + + useEffect(() => { + updateCreation({ selectedDeviceTypes: formDeviceTypes }); + }, [formDeviceTypes, updateCreation]); + useEffect(() => { updateCreation({ destination, @@ -67,17 +76,6 @@ export const ArtifactInformation = ({ creation = {}, deviceTypes = [], onRemove, }); }, [destination, name, selectedDeviceTypes.length, updateCreation]); - const onSelectionChanged = useCallback( - ({ currentValue = '', selection = [] }) => { - updateCreation({ - customDeviceTypes: currentValue, - isValid: (currentValue.length || selection.length) && name && destination, - selectedDeviceTypes: selection - }); - }, - [destination, name, updateCreation] - ); - const onDestinationChange = ({ target: { value } }) => updateCreation({ destination: value, isValid: checkDestinationValidity(value) && selectedDeviceTypes.length && name }); @@ -114,14 +112,16 @@ export const ArtifactInformation = ({ creation = {}, deviceTypes = [], onRemove, onChange={e => updateCreation({ name: e.target.value })} /> - + +
+ + +
); }; From a5485f7ea131883fe1275fb2d7b60b5fb56eb5c3 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:19:22 +0200 Subject: [PATCH 5/9] feat: added support for release tags & update type filtering Ticket: MEN-6455 Changelog: Title Signed-off-by: Manuel Zedel --- src/js/actions/releaseActions.js | 55 +++++++-- src/js/actions/releaseActions.test.js | 48 ++++++++ src/js/components/releases/releasedetails.js | 110 ++++++++++-------- src/js/components/releases/releases.js | 112 +++++++++++++------ src/js/components/releases/releases.test.js | 11 +- src/js/components/releases/releaseslist.js | 14 +-- src/js/constants/releaseConstants.js | 2 + src/js/reducers/releaseReducer.js | 23 +++- src/js/reducers/releaseReducer.test.js | 4 +- src/js/selectors/index.js | 3 +- src/js/utils/locationutils.js | 19 ++-- src/js/utils/locationutils.test.js | 4 +- tests/__mocks__/releaseHandlers.js | 18 ++- 13 files changed, 305 insertions(+), 118 deletions(-) diff --git a/src/js/actions/releaseActions.js b/src/js/actions/releaseActions.js index 7562a36574..81e8aaf769 100644 --- a/src/js/actions/releaseActions.js +++ b/src/js/actions/releaseActions.js @@ -20,7 +20,8 @@ import { SORTING_OPTIONS, TIMEOUTS, UPLOAD_PROGRESS } from '../constants/appCons import { DEVICE_LIST_DEFAULTS, emptyFilter } from '../constants/deviceConstants'; import * as ReleaseConstants from '../constants/releaseConstants'; import { customSort, deepCompare, duplicateFilter, extractSoftwareItem } from '../helpers'; -import { deploymentsApiUrl } from './deploymentActions'; +import { formatReleases } from '../utils/locationutils'; +import { deploymentsApiUrl, deploymentsApiUrlV2 } from './deploymentActions'; import { convertDeviceListStateToFilters, getSearchEndpoint } from './deviceActions'; const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; @@ -283,28 +284,30 @@ export const setReleasesListState = selectionState => (dispatch, getState) => { /* Releases */ const releaseListRetrieval = config => { - const { searchTerm = '', page = defaultPage, perPage = defaultPerPage, sort = {}, selectedTags = [] } = config; + const { searchTerm = '', page = defaultPage, perPage = defaultPerPage, sort = {}, selectedTags = [], type = '' } = config; const { key: attribute, direction } = sort; - - const sorting = attribute ? `&sort=${attribute}:${direction}`.toLowerCase() : ''; - const searchQuery = searchTerm ? `&name=${searchTerm}` : ''; - const tagQuery = selectedTags.map(tag => `&tag=${tag}`).join(''); - return GeneralApi.get(`${deploymentsApiUrl}/deployments/releases/list?page=${page}&per_page=${perPage}${searchQuery}${sorting}${tagQuery}`); + const filterQuery = formatReleases({ pageState: { searchTerm, selectedTags } }); + const updateType = type ? `update_type=${type}` : ''; + const sorting = attribute ? `sort=${attribute}:${direction}`.toLowerCase() : ''; + return GeneralApi.get( + `${deploymentsApiUrlV2}/deployments/releases?${[`page=${page}`, `per_page=${perPage}`, filterQuery, updateType, sorting].filter(i => i).join('&')}` + ); }; const deductSearchState = (receivedReleases, config, total, state) => { let releaseListState = { ...state.releasesList }; - const { searchTerm, searchOnly, sort = {} } = config; + const { searchTerm, searchOnly, sort = {}, tags = [], type } = config; const flattenedReleases = Object.values(receivedReleases).sort(customSort(sort.direction === SORTING_OPTIONS.desc, sort.key)); const releaseIds = flattenedReleases.map(item => item.Name); + const isFiltering = !!(tags.length || type || searchTerm); if (searchOnly) { releaseListState = { ...releaseListState, searchedIds: releaseIds }; } else { releaseListState = { ...releaseListState, releaseIds, - searchTotal: searchTerm ? total : state.releasesList.searchTotal, - total: !searchTerm ? total : state.releasesList.total + searchTotal: isFiltering ? total : state.releasesList.searchTotal, + total: !isFiltering ? total : state.releasesList.total }; } return releaseListState; @@ -337,3 +340,35 @@ export const getRelease = name => (dispatch, getState) => } return Promise.resolve(null); }); + +export const updateReleaseInfo = (name, info) => (dispatch, getState) => + GeneralApi.patch(`${deploymentsApiUrlV2}/deployments/releases/${name}`, info) + .catch(err => commonErrorHandler(err, `Release details couldn't be updated.`, dispatch)) + .then(() => { + return Promise.all([ + dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release: { ...getState().releases.byId[name], ...info } }), + dispatch(setSnackbar('Release details were updated successfully.', TIMEOUTS.fiveSeconds, '')) + ]); + }); + +export const setReleaseTags = + (name, tags = []) => + (dispatch, getState) => + GeneralApi.put(`${deploymentsApiUrlV2}/deployments/releases/${name}/tags`, tags) + .catch(err => commonErrorHandler(err, `Release tags couldn't be set.`, dispatch)) + .then(() => { + return Promise.all([ + dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release: { ...getState().releases.byId[name], tags } }), + dispatch(setSnackbar('Release tags were set successfully.', TIMEOUTS.fiveSeconds, '')) + ]); + }); + +export const getExistingReleaseTags = () => dispatch => + GeneralApi.get(`${deploymentsApiUrlV2}/releases/all/tags`) + .catch(err => commonErrorHandler(err, `Existing release tags couldn't be retrieved.`, dispatch)) + .then(({ data: tags }) => Promise.resolve(dispatch({ type: ReleaseConstants.RECEIVE_RELEASE_TAGS, tags }))); + +export const getUpdateTypes = () => dispatch => + GeneralApi.get(`${deploymentsApiUrlV2}/releases/all/types`) + .catch(err => commonErrorHandler(err, `Existing update types couldn't be retrieved.`, dispatch)) + .then(({ data: types }) => Promise.resolve(dispatch({ type: ReleaseConstants.RECEIVE_RELEASE_TYPES, types }))); diff --git a/src/js/actions/releaseActions.test.js b/src/js/actions/releaseActions.test.js index 9856fef158..fb24321e04 100644 --- a/src/js/actions/releaseActions.test.js +++ b/src/js/actions/releaseActions.test.js @@ -23,11 +23,15 @@ import { editArtifact, getArtifactInstallCount, getArtifactUrl, + getExistingReleaseTags, getRelease, getReleases, + getUpdateTypes, removeArtifact, removeRelease, selectRelease, + setReleaseTags, + updateReleaseInfo, uploadArtifact } from './releaseActions'; @@ -270,4 +274,48 @@ describe('release actions', () => { expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); + it('should retrieve existing release tags', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [{ type: ReleaseConstants.RECEIVE_RELEASE_TAGS, tags: ['foo', 'bar'] }]; + await store.dispatch(getExistingReleaseTags()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should retrieve existing release tags', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [{ type: ReleaseConstants.RECEIVE_RELEASE_TYPES, types: ['single-file', 'not-this'] }]; + await store.dispatch(getUpdateTypes()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow setting new release tags', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { + type: ReleaseConstants.RECEIVE_RELEASE, + release: { ...defaultState.releases.byId.r1, tags: ['foo', 'bar'] } + }, + { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Release tags were set successfully.' } } + ]; + await store.dispatch(setReleaseTags(defaultState.releases.byId.r1.Name, ['foo', 'bar'])); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow extending the release info', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { + type: ReleaseConstants.RECEIVE_RELEASE, + release: { ...defaultState.releases.byId.r1, notes: 'this & that' } + }, + { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Release details were updated successfully.' } } + ]; + await store.dispatch(updateReleaseInfo(defaultState.releases.byId.r1.Name, { notes: 'this & that' })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); }); diff --git a/src/js/components/releases/releasedetails.js b/src/js/components/releases/releasedetails.js index 7ff86c0824..d340602cbb 100644 --- a/src/js/components/releases/releasedetails.js +++ b/src/js/components/releases/releasedetails.js @@ -11,7 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; @@ -23,17 +24,17 @@ import { Replay as ReplayIcon, Sort as SortIcon } from '@mui/icons-material'; -import { Button, Collapse, Divider, Drawer, TextField, IconButton, SpeedDial, SpeedDialAction, SpeedDialIcon, Tooltip } from '@mui/material'; +import { Divider, Drawer, IconButton, SpeedDial, SpeedDialAction, SpeedDialIcon, TextField, Tooltip } from '@mui/material'; import { speedDialActionClasses } from '@mui/material/SpeedDialAction'; import { makeStyles } from 'tss-react/mui'; import copy from 'copy-to-clipboard'; import { setSnackbar } from '../../actions/appActions'; -import { removeArtifact, removeRelease, selectArtifact, selectRelease } from '../../actions/releaseActions'; +import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '../../actions/releaseActions'; import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; import { FileSize, customSort, formatTime, toggle } from '../../helpers'; -import { getFeatures, getSelectedRelease, getUserCapabilities } from '../../selectors'; +import { getReleaseTags, getSelectedRelease, getUserCapabilities } from '../../selectors'; import useWindowSize from '../../utils/resizehook'; import ChipSelect from '../common/chipselect'; import { ConfirmationButtons, EditButton } from '../common/confirm'; @@ -42,8 +43,6 @@ import { RelativeTime } from '../common/time'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; import Artifact from './artifact'; import RemoveArtifactDialog from './dialogs/removeartifact'; -import ExpandableAttribute from '../common/expandable-attribute'; -import { ConfirmationButtons, EditButton } from '../common/confirm'; const DeviceTypeCompatibility = ({ artifact }) => { const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', '); @@ -105,7 +104,7 @@ const useStyles = makeStyles()(theme => ({ } }, fab: { margin: theme.spacing(2) }, - tagSelect: { maxWidth: 350 }, + tagSelect: { marginRight: theme.spacing(2), maxWidth: 350 }, label: { marginRight: theme.spacing(2), marginBottom: theme.spacing(4) @@ -221,50 +220,58 @@ export const EditableLongText = ({ contentFallback = '', fullWidth, original, on ); }; +const ReleaseNotes = ({ onChange, release: { notes = '' } }) => ( + <> +

Release notes

+ + +); + +const ReleaseTags = ({ existingTags = [], release: { tags = [] }, onChange }) => { const [isEditing, setIsEditing] = useState(false); + const [initialValues] = useState({ tags }); + const { classes } = useStyles(); - const onToggleEdit = () => { - setSelectedTags(existingTags); - setIsEditing(toggle); - }; + const methods = useForm({ mode: 'onChange', defaultValues: initialValues }); + const { setValue, getValues } = methods; - const onTagSelectionChanged = ({ selection }) => setSelectedTags(selection); + useEffect(() => { + if (!initialValues.tags.length) { + setValue('tags', tags); + } + }, [initialValues.tags, setValue, tags]); + + const onToggleEdit = useCallback(() => { + setValue('tags', tags); + setIsEditing(toggle); + }, [setValue, tags]); const onSave = () => { - console.log('saving tags', selectedTags); + onChange(getValues('tags')); + setIsEditing(false); }; - const { classes } = useStyles(); - return ( -
+

Tags

- {!isEditing && ( - - )} + {!isEditing && } +
+
+ +
+ + +
+ {isEditing && }
- - -
- - -
-
); }; @@ -303,7 +310,7 @@ const ArtifactsList = ({ artifacts, selectedArtifact, setSelectedArtifact, setSh {columns.map(item => (
sortColumn(item)}> - {item.title} + <>{item.title} {item.sortable ? : null} {item.tooltip} @@ -344,31 +351,37 @@ export const ReleaseDetails = () => { const navigate = useNavigate(); const dispatch = useDispatch(); const release = useSelector(getSelectedRelease); + const existingTags = useSelector(getReleaseTags); const userCapabilities = useSelector(getUserCapabilities); + const { Name: releaseName, Artifacts: artifacts = [] } = release; + const onRemoveArtifact = artifact => dispatch(removeArtifact(artifact.id)).finally(() => setShowRemoveArtifactDialog(false)); const copyLinkToClipboard = () => { const location = window.location.href.substring(0, window.location.href.indexOf('/releases') + '/releases'.length); - copy(`${location}/${release.Name}`); + copy(`${location}/${releaseName}`); dispatch(setSnackbar('Link copied to clipboard')); }; const onCloseClick = () => dispatch(selectRelease()); - const onCreateDeployment = () => navigate(`${DEPLOYMENT_ROUTES.active.route}?open=true&release=${encodeURIComponent(release.Name)}`); + const onCreateDeployment = () => navigate(`${DEPLOYMENT_ROUTES.active.route}?open=true&release=${encodeURIComponent(releaseName)}`); const onToggleReleaseDeletion = () => setConfirmReleaseDeletion(toggle); - const onDeleteRelease = () => dispatch(removeRelease(release.Name)).then(() => setConfirmReleaseDeletion(false)); + const onDeleteRelease = () => dispatch(removeRelease(releaseName)).then(() => setConfirmReleaseDeletion(false)); + + const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo(releaseName, { notes })), [dispatch, releaseName]); + + const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags(releaseName, tags)), [dispatch, releaseName]); - const artifacts = release.Artifacts ?? []; return ( - +
- Release information for {release.Name} + Release information for {releaseName} @@ -385,7 +398,8 @@ export const ReleaseDetails = () => {
- {hasReleaseTags && } + + { +const DeltaProgress = ({ className = '' }) => { const isEnterprise = useSelector(getIsEnterprise); return ( -
+
{isEnterprise ? 'There is no automatic delta artifacts generation running.' : }
); @@ -64,23 +68,24 @@ const tabs = [ ]; const useStyles = makeStyles()(theme => ({ - filters: { maxWidth: 400, alignItems: 'end', columnGap: 50 }, + container: { maxWidth: 1600 }, searchNote: { minHeight: '1.8rem' }, tabContainer: { alignSelf: 'flex-start' }, uploadButton: { minWidth: 164, marginRight: theme.spacing(2) } })); -const Header = ({ canUpload, existingTags = [], features, hasReleases, releasesListState, setReleasesListState, onUploadClick }) => { - const { hasReleaseTags } = features; - const { selectedTags = [], searchTerm, searchTotal, tab = tabs[0].key, total } = releasesListState; +const Header = ({ canUpload, releasesListState, setReleasesListState, onUploadClick }) => { + const { selectedTags = [], searchTerm = '', searchTotal, tab = tabs[0].key, total, type } = releasesListState; const { classes } = useStyles(); const hasReleases = useSelector(getHasReleases); + const existingTags = useSelector(getReleaseTags); + const updateTypes = useSelector(getUpdateTypesSelector); const searchUpdated = useCallback(searchTerm => setReleasesListState({ searchTerm }), [setReleasesListState]); const onTabChanged = (e, tab) => setReleasesListState({ tab }); - const onTagSelectionChanged = ({ selection }) => setReleasesListState({ selectedTags: selection }); + const onFiltersChange = useCallback(({ name, tags, type }) => setReleasesListState({ selectedTags: tags, searchTerm: name, type }), [setReleasesListState]); return (
@@ -100,19 +105,47 @@ const Header = ({ canUpload, existingTags = [], features, hasReleases, releasesL )}
{hasReleases && tab === tabs[0].key && ( -
- - {hasReleaseTags && ( - - )} -
+ + } + } + ]} + /> )}

{searchTerm && searchTotal !== total ? `Filtered from ${total} ${pluralize('Release', total)}` : ''}

@@ -120,20 +153,20 @@ const Header = ({ canUpload, existingTags = [], features, hasReleases, releasesL }; export const Releases = () => { - const features = useSelector(getFeatures); const releasesListState = useSelector(getReleaseListState); - const { searchTerm, sort = {}, page, perPage, tab = tabs[0].key, selectedTags } = releasesListState; + const { searchTerm, sort = {}, page, perPage, tab = tabs[0].key, selectedTags, type } = releasesListState; const releases = useSelector(getReleasesList); - const releaseTags = useSelector(state => state.releases.releaseTags); const selectedRelease = useSelector(getSelectedRelease); const { canUploadReleases } = useSelector(getUserCapabilities); const dispatch = useDispatch(); + const { classes } = useStyles(); const [selectedFile, setSelectedFile] = useState(); const [showAddArtifactDialog, setShowAddArtifactDialog] = useState(false); const artifactTimer = useRef(); const [locationParams, setLocationParams] = useLocationParams('releases', { defaults: { direction: SORTING_OPTIONS.desc, key: 'modified' } }); const debouncedSearchTerm = useDebounce(searchTerm, TIMEOUTS.debounceDefault); + const debouncedTypeFilter = useDebounce(type, TIMEOUTS.debounceDefault); useEffect(() => { if (!artifactTimer.current) { @@ -141,7 +174,19 @@ export const Releases = () => { } setLocationParams({ pageState: { ...releasesListState, selectedRelease: selectedRelease.Name } }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearchTerm, JSON.stringify(sort), page, perPage, selectedRelease.Name, setLocationParams, tab, JSON.stringify(selectedTags)]); + }, [ + debouncedSearchTerm, + debouncedTypeFilter, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(sort), + page, + perPage, + selectedRelease.Name, + setLocationParams, + tab, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(selectedTags) + ]); useEffect(() => { const { selectedRelease, tags, ...remainder } = locationParams; @@ -157,6 +202,12 @@ export const Releases = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, JSON.stringify(locationParams)]); + useEffect(() => { + dispatch(getReleases({ searchTerm: '', searchOnly: true, page: 1, perPage: 1, selectedTags: [], type: '' })); + dispatch(getExistingReleaseTags()); + dispatch(getUpdateTypes()); + }, [dispatch]); + const onUploadClick = () => setShowAddArtifactDialog(true); const onFileUploadClick = selectedFile => { @@ -174,14 +225,11 @@ export const Releases = () => {
- +
{showAddArtifactDialog && ( diff --git a/src/js/components/releases/releases.test.js b/src/js/components/releases/releases.test.js index ac8301dd68..0631ca929b 100644 --- a/src/js/components/releases/releases.test.js +++ b/src/js/components/releases/releases.test.js @@ -43,20 +43,25 @@ describe('Releases Component', () => { const { rerender } = render(ui, { preloadedState }); await waitFor(() => expect(screen.queryAllByText(defaultState.releases.byId.r1.Name)[0]).toBeInTheDocument()); await user.click(screen.getAllByText(defaultState.releases.byId.r1.Name)[0]); - expect(screen.queryByDisplayValue(defaultState.releases.byId.r1.Artifacts[0].description)).toBeInTheDocument(); + await user.click(screen.getByText(/qemux/i)); + expect(screen.queryByText(defaultState.releases.byId.r1.Artifacts[0].description)).toBeVisible(); await user.click(screen.getByRole('button', { name: /Remove this/i })); await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: /Cancel/i })); await waitFor(() => expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument()); await user.click(screen.getByRole('button', { name: /Close/i })); await waitFor(() => rerender(ui)); - expect(screen.queryByDisplayValue(defaultState.releases.byId.r1.Artifacts[0].description)).not.toBeInTheDocument(); + await act(async () => { + jest.runOnlyPendingTimers(); + jest.runAllTicks(); + }); + expect(screen.queryByText(/release information/i)).toBeFalsy(); }); it('has working search handling as expected', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(); expect(screen.queryByText(/Filtered from/i)).not.toBeInTheDocument(); - await user.type(screen.getByPlaceholderText(/Search/i), 'b1'); + await user.type(screen.getByPlaceholderText(/starts with/i), 'b1'); await waitFor(() => expect(screen.queryByText(/Filtered from/i)).toBeInTheDocument(), { timeout: 2000 }); expect(screen.queryByText(/Filtered from/i)).toBeInTheDocument(); }); diff --git a/src/js/components/releases/releaseslist.js b/src/js/components/releases/releaseslist.js index 277079ac0c..729376e2f3 100644 --- a/src/js/components/releases/releaseslist.js +++ b/src/js/components/releases/releaseslist.js @@ -46,7 +46,7 @@ const columns = [ key: 'tags', title: 'Tags', render: ({ tags = [] }) => tags.join(', ') || '-', - canShow: ({ features: { hasReleaseTags } }) => hasReleaseTags + canShow }, { key: 'modified', @@ -59,7 +59,6 @@ const columns = [ ]; const useStyles = makeStyles()(() => ({ - container: { maxWidth: 1600 }, empty: { margin: '8vh auto' } })); @@ -85,12 +84,12 @@ const EmptyState = ({ canUpload, className = '', dropzoneRef, uploading, onDrop,
); -export const ReleasesList = ({ onFileUploadClick }) => { +export const ReleasesList = ({ className = '', onFileUploadClick }) => { const repoRef = useRef(); const dropzoneRef = useRef(); const uploading = useSelector(state => state.app.uploading); const releasesListState = useSelector(getReleaseListState); - const { isLoading, page = defaultPage, perPage = defaultPerPage, searchTerm, sort = {}, searchTotal, tags = [], total } = releasesListState; + const { isLoading, page = defaultPage, perPage = defaultPerPage, searchTerm, sort = {}, searchTotal, tags = [], total, type } = releasesListState; const hasReleases = useSelector(getHasReleases); const features = useSelector(getFeatures); const releases = useSelector(getReleasesList); @@ -134,7 +133,8 @@ export const ReleasesList = ({ onFileUploadClick }) => { [JSON.stringify(features)] ); - const potentialTotal = searchTerm ? searchTotal : total; + const isFiltering = !!(tags.length || type || searchTerm); + const potentialTotal = isFiltering ? searchTotal : total; if (!hasReleases) { return ( { } return ( -
+
{isLoading === undefined ? ( ) : !potentialTotal ? ( -

There are no Releases {searchTerm ? `for ${searchTerm}` : 'yet'}

+

There are no Releases {isFiltering ? 'for the filter selection' : 'yet'}

) : ( <> diff --git a/src/js/constants/releaseConstants.js b/src/js/constants/releaseConstants.js index 36ed7543a4..35544aae97 100644 --- a/src/js/constants/releaseConstants.js +++ b/src/js/constants/releaseConstants.js @@ -17,6 +17,8 @@ export const ARTIFACTS_SET_ARTIFACT_URL = 'ARTIFACTS_SET_ARTIFACT_URL'; export const UPDATED_ARTIFACT = 'UPDATED_ARTIFACT'; export const RECEIVE_ARTIFACTS = 'RECEIVE_ARTIFACTS'; export const RECEIVE_RELEASE = 'RECEIVE_RELEASE'; +export const RECEIVE_RELEASE_TAGS = 'RECEIVE_RELEASE_TAGS'; +export const RECEIVE_RELEASE_TYPES = 'RECEIVE_RELEASE_TYPES'; export const RECEIVE_RELEASES = 'RECEIVE_RELEASES'; export const RELEASE_REMOVED = 'RELEASE_REMOVED'; export const SELECTED_RELEASE = 'SELECTED_RELEASE'; diff --git a/src/js/reducers/releaseReducer.js b/src/js/reducers/releaseReducer.js index 866960c2dd..248d150c1d 100644 --- a/src/js/reducers/releaseReducer.js +++ b/src/js/reducers/releaseReducer.js @@ -48,7 +48,9 @@ export const initialState = { ], modified: '' device_types_compatible, - Name: '' + Name: '', + tags: ['something'], + notes: '' } */ }, @@ -63,9 +65,12 @@ export const initialState = { isLoading: undefined, searchTerm: '', searchTotal: 0, - total: 0 + tags: [], + total: 0, + type: '' }, - releaseTags: [], + tags: [], + updateTypes: [], /* * Return single release with corresponding Artifacts */ @@ -85,6 +90,16 @@ const releaseReducer = (state = initialState, action) => { [action.release.Name]: action.release } }; + case ReleaseConstants.RECEIVE_RELEASE_TAGS: + return { + ...state, + tags: action.tags + }; + case ReleaseConstants.RECEIVE_RELEASE_TYPES: + return { + ...state, + updateTypes: action.types + }; case ReleaseConstants.RECEIVE_RELEASES: { return { ...state, @@ -97,7 +112,7 @@ const releaseReducer = (state = initialState, action) => { return { ...state, byId, - selectedRelease: action.release === state.selectedRelease ? Object.keys(byId)[0] : state.selectedRelease + selectedRelease: action.release === state.selectedRelease ? null : state.selectedRelease }; } case ReleaseConstants.SELECTED_RELEASE: diff --git a/src/js/reducers/releaseReducer.test.js b/src/js/reducers/releaseReducer.test.js index c05b967008..bb05766f77 100644 --- a/src/js/reducers/releaseReducer.test.js +++ b/src/js/reducers/releaseReducer.test.js @@ -92,10 +92,10 @@ describe('release reducer', () => { expect( reducer({ ...initialState, byId: { test: testRelease }, selectedRelease: 'test' }, { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' }) .selectedRelease - ).toEqual(undefined); + ).toEqual(null); expect( reducer( - { ...initialState, byId: { test: testRelease, test2: testRelease }, selectedRelease: 'test' }, + { ...initialState, byId: { test: testRelease, test2: testRelease }, selectedRelease: 'test2' }, { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' } ).selectedRelease ).toEqual('test2'); diff --git a/src/js/selectors/index.js b/src/js/selectors/index.js index 770f79413b..6f6f7e762e 100644 --- a/src/js/selectors/index.js +++ b/src/js/selectors/index.js @@ -57,9 +57,10 @@ export const getGlobalSettings = state => state.users.globalSettings; const getIssueCountsByType = state => state.monitor.issueCounts.byType; const getSelectedReleaseId = state => state.releases.selectedRelease; export const getReleasesById = state => state.releases.byId; -const getReleaseTags = state => state.releases.releaseTags; +export const getReleaseTags = state => state.releases.tags; export const getReleaseListState = state => state.releases.releasesList; const getListedReleases = state => state.releases.releasesList.releaseIds; +export const getUpdateTypes = state => state.releases.updateTypes; export const getExternalIntegrations = state => state.organization.externalDeviceIntegrations; const getDeploymentsById = state => state.deployments.byId; export const getDeploymentsByStatus = state => state.deployments.byStatus; diff --git a/src/js/utils/locationutils.js b/src/js/utils/locationutils.js index 08f75f220c..e932a46f18 100644 --- a/src/js/utils/locationutils.js +++ b/src/js/utils/locationutils.js @@ -387,21 +387,24 @@ export const generateDeploymentsPath = ({ pageState }) => { }; const releasesRoot = '/releases'; -export const formatReleases = ({ pageState: { selectedTags = [], tab } }) => { - const formattedFilters = selectedTags.map(tag => `tag=${tag}`); - if (tab) { - formattedFilters.push(`tab=${tab}`); - } - return formattedFilters.join('&'); -}; +export const formatReleases = ({ pageState: { searchTerm, selectedTags = [], tab, type } }) => + Object.entries({ name: searchTerm, tab, type }) + .reduce( + (accu, [key, value]) => (value ? [...accu, `${key}=${value}`] : accu), + selectedTags.map(tag => `tag=${tag}`) + ) + .join('&'); + export const generateReleasesPath = ({ pageState: { selectedRelease } }) => `${releasesRoot}${selectedRelease ? `/${selectedRelease}` : ''}`; export const parseReleasesQuery = (queryParams, extraProps) => { + const name = queryParams.has('name') ? queryParams.get('name') : ''; const tab = queryParams.has('tab') ? queryParams.get('tab') : undefined; const tags = queryParams.has('tag') ? queryParams.getAll('tag') : []; + const type = queryParams.has('type') ? queryParams.get('type') : ''; let selectedRelease = extraProps.location.pathname.substring(releasesRoot.length + 1); if (!selectedRelease && extraProps.pageState.id?.length) { selectedRelease = extraProps.pageState.id[0]; } - return { selectedRelease, tab, tags }; + return { searchTerm: name, selectedRelease, tab, tags, type }; }; diff --git a/src/js/utils/locationutils.test.js b/src/js/utils/locationutils.test.js index edd2718b95..79e42ca186 100644 --- a/src/js/utils/locationutils.test.js +++ b/src/js/utils/locationutils.test.js @@ -299,9 +299,11 @@ describe('locationutils', () => { const endDate = new Date('2019-01-13'); endDate.setHours(23, 59, 59, 999); expect(result).toEqual({ + searchTerm: '', selectedRelease: 'terst', tab: 'flump', - tags: ['asd', '52534'] + tags: ['asd', '52534'], + type: '' }); }); it('uses working utilities - generateReleasesPath', () => { diff --git a/tests/__mocks__/releaseHandlers.js b/tests/__mocks__/releaseHandlers.js index c8a67ac270..5eb37bb2f7 100644 --- a/tests/__mocks__/releaseHandlers.js +++ b/tests/__mocks__/releaseHandlers.js @@ -13,7 +13,7 @@ // limitations under the License. import { rest } from 'msw'; -import { deploymentsApiUrl } from '../../src/js/actions/deploymentActions'; +import { deploymentsApiUrl, deploymentsApiUrlV2 } from '../../src/js/actions/deploymentActions'; import { headerNames } from '../../src/js/api/general-api'; import { SORTING_OPTIONS } from '../../src/js/constants/appConstants'; import { customSort } from '../../src/js/helpers'; @@ -35,7 +35,7 @@ export const releaseHandlers = [ }), rest.post(`${deploymentsApiUrl}/artifacts/generate`, (req, res, ctx) => res(ctx.status(200))), rest.post(`${deploymentsApiUrl}/artifacts`, (req, res, ctx) => res(ctx.status(200))), - rest.get(`${deploymentsApiUrl}/deployments/releases/list`, ({ url: { searchParams } }, res, ctx) => { + rest.get(`${deploymentsApiUrlV2}/deployments/releases`, ({ url: { searchParams } }, res, ctx) => { const page = Number(searchParams.get('page')); const perPage = Number(searchParams.get('per_page')); if (!page || ![1, 10, 20, 50, 100, 250, 500].includes(perPage)) { @@ -53,5 +53,19 @@ export const releaseHandlers = [ return res(ctx.set(headerNames.total, 1234), ctx.json(releaseListSection)); } return res(ctx.set(headerNames.total, releasesList.length), ctx.json(releaseListSection)); + }), + rest.get(`${deploymentsApiUrlV2}/releases/all/tags`, (_, res, ctx) => res(ctx.json(['foo', 'bar']))), + rest.get(`${deploymentsApiUrlV2}/releases/all/types`, (_, res, ctx) => res(ctx.json(['single-file', 'not-this']))), + rest.put(`${deploymentsApiUrlV2}/deployments/releases/:name/tags`, ({ params: { name }, body: tags }, res, ctx) => { + if (name && tags.every(i => i && i.toString() === i)) { + return res(ctx.status(200)); + } + return res(ctx.status(593)); + }), + rest.patch(`${deploymentsApiUrlV2}/deployments/releases/:name`, ({ params: { name }, body: { notes } }, res, ctx) => { + if (name && notes.length) { + return res(ctx.status(200)); + } + return res(ctx.status(594)); }) ]; From e2579573b11febae3a173525a9fa2d3ba05f71e0 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:43:11 +0200 Subject: [PATCH 6/9] chore(e2e-tests): added tests for release tag functionality Ticket: MEN-6352 Changelog: None Signed-off-by: Manuel Zedel --- tests/e2e_tests/integration/03-files.spec.ts | 73 ++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/e2e_tests/integration/03-files.spec.ts b/tests/e2e_tests/integration/03-files.spec.ts index 5b43c89883..04f4248002 100644 --- a/tests/e2e_tests/integration/03-files.spec.ts +++ b/tests/e2e_tests/integration/03-files.spec.ts @@ -37,6 +37,79 @@ test.describe('Files', () => { await page.waitForTimeout(timeouts.fiveSeconds); }); + test('allows release notes manipulation', async ({ loggedInPage: page }) => { + await page.click(`.leftNav :text('Releases')`); + await page.getByText(/demo-artifact/i).click(); + expect(await page.getByRole('heading', { name: /Release notes/i }).isVisible()).toBeTruthy(); + // layout based locators are not an option here, since the edit button is also visible on the nearby tags section + // and the selector would get confused due to the proximity - so instead we loop over all the divs + await page + .locator('div') + .filter({ hasText: /^Add release notes here Edit$/i }) + .getByRole('button') + .click(); + const input = await page.getByPlaceholder(/release notes/i); + await input.fill('foo notes'); + await page.getByTestId('CheckIcon').click(); + expect(input).not.toBeVisible(); + expect(await page.getByText('foo notes').isVisible()).toBeTruthy(); + }); + + test('allows release tags manipulation', async ({ baseUrl, loggedInPage: page }) => { + await page.click(`.leftNav :text('Releases')`); + const alreadyTagged = await page.getByText('some, tags').isVisible(); + test.skip(alreadyTagged, 'looks like the release was tagged already'); + await page.getByText(/demo-artifact/i).click(); + expect(await page.getByRole('heading', { name: /Release notes/i }).isVisible()).toBeTruthy(); + expect(await page.getByRole('button', { name: 'some' }).isVisible()).not.toBeTruthy(); + // layout based locators are not an option here, since the edit button is also visible on the nearby release notes section + // and the selector would get confused due to the proximity - so instead we loop over all the divs + const theDiv = await page + .locator('div') + .filter({ has: page.getByRole('heading', { name: /tags/i }), hasNotText: /notes/i }) + .filter({ has: page.getByRole('button', { name: /edit/i }) }); + const editButton = await theDiv.getByRole('button', { name: /edit/i }); + await editButton.click(); + const input = await page.getByPlaceholder(/enter release tags/i); + await input.fill('some,tags'); + await page.getByTestId('CheckIcon').click(); + expect(input).not.toBeVisible(); + await page.goto(`${baseUrl}ui/releases`); + expect(await page.getByText('some, tags').isVisible()).toBeTruthy(); + }); + + test('allows release tags reset', async ({ loggedInPage: page }) => { + await page.click(`.leftNav :text('Releases')`); + await page.getByText(/demo-artifact/i).click(); + const theDiv = await page + .locator('div') + .filter({ has: page.getByRole('heading', { name: /tags/i }), hasNotText: /notes/ }) + .filter({ has: page.getByRole('button', { name: /edit/i }) }); + const editButton = await theDiv.getByRole('button', { name: /edit/i }); + await editButton.click(); + const alreadyTagged = await page.getByRole('button', { name: 'some' }).isVisible(); + if (alreadyTagged) { + await page.getByRole('button', { name: 'some' }).getByTestId('CancelIcon').click(); + await page.getByRole('button', { name: 'tags' }).getByTestId('CancelIcon').click(); + await page.getByTestId('CheckIcon').click(); + expect(await page.getByText('add release tags').isVisible()).toBeTruthy(); + await editButton.click(); + } + await page.getByPlaceholder(/enter release tags/i).fill('someTag'); + await page.getByTestId('CheckIcon').click(); + await page.press('body', 'Escape'); + expect(await page.getByText('someTag').isVisible()).toBeTruthy(); + }); + + test('allows release tags filtering', async ({ loggedInPage: page }) => { + await page.click(`.leftNav :text('Releases')`); + expect(await page.getByText('someTag').isVisible()).toBeTruthy(); + await page.getByPlaceholder(/select tags/i).fill('foo,'); + expect(await page.getByText('someTag').isVisible()).not.toBeTruthy(); + await page.getByText(/Clear filter/i).click(); + expect(await page.getByText('someTag').isVisible()).toBeTruthy(); + }); + // test('allows uploading custom file creations', () => { // cy.exec('mender-artifact write rootfs-image -f core-image-full-cmdline-qemux86-64.ext4 -t qemux86-64 -n release1 -o qemux86-64_release_1.mender') // .then(result => { From 0fed58bb873e2f47244ed8865e34227bda689664 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 12 Sep 2023 09:16:23 +0200 Subject: [PATCH 7/9] chore(e2e-tests): made debugging slightly easier by having videos more often Signed-off-by: Manuel Zedel --- tests/e2e_tests/integration/02-layoutAssertions.spec.ts | 4 +--- tests/e2e_tests/playwright.config.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/e2e_tests/integration/02-layoutAssertions.spec.ts b/tests/e2e_tests/integration/02-layoutAssertions.spec.ts index 635df95773..6b4da24289 100644 --- a/tests/e2e_tests/integration/02-layoutAssertions.spec.ts +++ b/tests/e2e_tests/integration/02-layoutAssertions.spec.ts @@ -65,9 +65,7 @@ test.describe('Layout assertions', () => { test('can group a device', async ({ loggedInPage: page }) => { const wasGrouped = await page.isVisible(`.grouplist:has-text('testgroup')`); - if (wasGrouped) { - test.skip('looks like the device was grouped already, continue with the remaining tests'); - } + test.skip(wasGrouped, 'looks like the device was grouped already, continue with the remaining tests'); await page.click(`.leftNav :text('Devices')`); await page.click(selectors.deviceListCheckbox); await page.hover('.MuiSpeedDial-fab'); diff --git a/tests/e2e_tests/playwright.config.ts b/tests/e2e_tests/playwright.config.ts index be9dee1112..1013d5d955 100644 --- a/tests/e2e_tests/playwright.config.ts +++ b/tests/e2e_tests/playwright.config.ts @@ -33,7 +33,7 @@ export const contextOptions = { ...contextArgs, contextOptions: contextArgs, screenshot: 'only-on-failure', - video: 'retry-with-video', + video: 'retain-on-failure', // headless: false, launchOptions }; From 6bd051c687fbb8e7a17f93ad791fbf68d836dd07 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 8 Sep 2023 16:43:34 +0200 Subject: [PATCH 8/9] chore: aligned snapshots with updated implementation Signed-off-by: Manuel Zedel --- src/js/__snapshots__/main.test.js.snap | 65 +- .../components/__snapshots__/app.test.js.snap | 66 +- .../devicenameinput.test.js.snap | 88 +- .../common/__snapshots__/search.test.js.snap | 104 +- .../expanded-device.test.js.snap | 469 ++++----- .../__snapshots__/configuration.test.js.snap | 1 + .../__snapshots__/identity.test.js.snap | 192 ++-- .../__snapshots__/artifact.test.js.snap | 446 ++++----- .../artifactdetails.test.js.snap | 488 ++++----- .../__snapshots__/releases.test.js.snap | 923 ++++++++++++++---- .../__snapshots__/releaseslist.test.js.snap | 189 ++-- 11 files changed, 1779 insertions(+), 1252 deletions(-) diff --git a/src/js/__snapshots__/main.test.js.snap b/src/js/__snapshots__/main.test.js.snap index b1a140c726..6210e57c8b 100644 --- a/src/js/__snapshots__/main.test.js.snap +++ b/src/js/__snapshots__/main.test.js.snap @@ -55,42 +55,51 @@ exports[`Main Component renders correctly 1`] = ` />
-
- - ​ - - + + ​ + + +
+
-
-
+ +
diff --git a/src/js/components/__snapshots__/app.test.js.snap b/src/js/components/__snapshots__/app.test.js.snap index d836cb4895..9a6484cf49 100644 --- a/src/js/components/__snapshots__/app.test.js.snap +++ b/src/js/components/__snapshots__/app.test.js.snap @@ -191,6 +191,7 @@ label+.emotion-2 { flex-shrink: 0; -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + font-size: 1.1607rem; color: rgba(0, 0, 0, 0.26); } @@ -1181,42 +1182,51 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-5:focus::-ms-input-p />
-
- - ​ - - + + ​ + + +
+
-
-
+ +
diff --git a/src/js/components/common/__snapshots__/devicenameinput.test.js.snap b/src/js/components/common/__snapshots__/devicenameinput.test.js.snap index 74fad0cef4..55a1a29b45 100644 --- a/src/js/components/common/__snapshots__/devicenameinput.test.js.snap +++ b/src/js/components/common/__snapshots__/devicenameinput.test.js.snap @@ -247,21 +247,21 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-1:focus::-ms-input-p -webkit-text-decoration: none; text-decoration: none; color: inherit; - text-align: center; - -webkit-flex: 0 0 auto; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - font-size: 1.3929rem; - padding: 8px; - border-radius: 50%; - overflow: visible; - color: rgba(0, 0, 0, 0.54); - -webkit-transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - padding: 5px; - font-size: 1.0446rem; - font-size: 1.2rem; - color: rgba(0, 0, 0, 0.54); + font-family: Lato,sans-serif; + font-weight: 500; + font-size: 0.7545rem; + line-height: 1.75; + text-transform: uppercase; + min-width: 64px; + padding: 4px 5px; + border-radius: 4px; + -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + color: #337a87; + border-radius: 2px; + font-size: 14px; + font-weight: bold; + padding: 10px 15px; } .emotion-3::-moz-focus-inner { @@ -281,7 +281,9 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-1:focus::-ms-input-p } .emotion-3:hover { - background-color: rgba(0, 0, 0, 0.04); + -webkit-text-decoration: none; + text-decoration: none; + background-color: rgba(51, 122, 135, 0.04); } @media (hover: none) { @@ -291,11 +293,28 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-1:focus::-ms-input-p } .emotion-3.Mui-disabled { - background-color: transparent; color: rgba(0, 0, 0, 0.26); } +.emotion-3:hover { + colors: #337a87; +} + +.emotion-3.MuiButton-text { + color: rgba(10, 10, 11, 0.78); +} + .emotion-4 { + display: inherit; + margin-right: 8px; + margin-left: -2px; +} + +.emotion-4>*:nth-of-type(1) { + font-size: 18px; +} + +.emotion-5 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -310,14 +329,13 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-1:focus::-ms-input-p -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; font-size: 1.3929rem; - font-size: 1.25rem; } -.emotion-4 iconButton { +.emotion-5 iconButton { margin-right: 8px; } -.emotion-5 { +.emotion-6 { overflow: hidden; pointer-events: none; position: absolute; @@ -344,23 +362,29 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-1:focus::-ms-input-p class="MuiInputAdornment-root MuiInputAdornment-positionEnd emotion-2" >
diff --git a/src/js/components/common/__snapshots__/search.test.js.snap b/src/js/components/common/__snapshots__/search.test.js.snap index 5d50eebbc1..74b2b0fa3e 100644 --- a/src/js/components/common/__snapshots__/search.test.js.snap +++ b/src/js/components/common/__snapshots__/search.test.js.snap @@ -148,6 +148,7 @@ label+.emotion-1 { flex-shrink: 0; -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + font-size: 1.1607rem; color: rgba(0, 0, 0, 0.26); } @@ -276,65 +277,74 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-4:focus::-ms-input-p margin-left: 8px; } -
- - ​ - - -
- -
+ + ​ + + +
+
- - - - +
+ + + + +
- + + `; diff --git a/src/js/components/devices/__snapshots__/expanded-device.test.js.snap b/src/js/components/devices/__snapshots__/expanded-device.test.js.snap index c8ec45087b..b749bc5b85 100644 --- a/src/js/components/devices/__snapshots__/expanded-device.test.js.snap +++ b/src/js/components/devices/__snapshots__/expanded-device.test.js.snap @@ -740,21 +740,21 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- -webkit-text-decoration: none; text-decoration: none; color: inherit; - text-align: center; - -webkit-flex: 0 0 auto; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - font-size: 1.3929rem; - padding: 8px; - border-radius: 50%; - overflow: visible; - color: rgba(0, 0, 0, 0.54); - -webkit-transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - padding: 5px; - font-size: 1.0446rem; - font-size: 1.2rem; - color: rgba(0, 0, 0, 0.54); + font-family: Lato,sans-serif; + font-weight: 500; + font-size: 0.7545rem; + line-height: 1.75; + text-transform: uppercase; + min-width: 64px; + padding: 4px 5px; + border-radius: 4px; + -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + color: #337a87; + border-radius: 2px; + font-size: 14px; + font-weight: bold; + padding: 10px 15px; } .emotion-39::-moz-focus-inner { @@ -774,7 +774,9 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- } .emotion-39:hover { - background-color: rgba(0, 0, 0, 0.04); + -webkit-text-decoration: none; + text-decoration: none; + background-color: rgba(51, 122, 135, 0.04); } @media (hover: none) { @@ -784,33 +786,28 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- } .emotion-39.Mui-disabled { - background-color: transparent; color: rgba(0, 0, 0, 0.26); } -.emotion-40 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - width: 1em; - height: 1em; - display: inline-block; - fill: currentColor; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - font-size: 1.3929rem; - font-size: 1.25rem; +.emotion-39:hover { + colors: #337a87; +} + +.emotion-39.MuiButton-text { + color: rgba(10, 10, 11, 0.78); } -.emotion-40 iconButton { +.emotion-40 { + display: inherit; margin-right: 8px; + margin-left: -2px; +} + +.emotion-40>*:nth-of-type(1) { + font-size: 18px; } -.emotion-43 { +.emotion-44 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -828,11 +825,11 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- color: #337a87; } -.emotion-43 iconButton { +.emotion-44 iconButton { margin-right: 8px; } -.emotion-47 { +.emotion-48 { background-color: #f7f7f7; border-color: #e9e9e9; border-style: solid; @@ -842,7 +839,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- padding: 16px; } -.emotion-48 { +.emotion-49 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -860,15 +857,15 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- color: #337a87; } -.emotion-48 iconButton { +.emotion-49 iconButton { margin-right: 8px; } -.emotion-48.read { +.emotion-49.read { color: rgba(0, 0, 0, 0.38); } -.emotion-49 { +.emotion-50 { position: absolute; top: -5px; bottom: 0; @@ -878,37 +875,37 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- border-radius: 50%; } -.emotion-49.read { +.emotion-50.read { border-color: rgba(0, 0, 0, 0.38); } -.emotion-50 .header, -.emotion-50 .MuiAccordion-root { +.emotion-51 .header, +.emotion-51 .MuiAccordion-root { border-bottom: 1px solid #cfcfcf; } -.emotion-50 .columnHeader, -.emotion-50 .MuiAccordionSummary-root, -.emotion-50 .MuiAccordionSummary-content { +.emotion-51 .columnHeader, +.emotion-51 .MuiAccordionSummary-root, +.emotion-51 .MuiAccordionSummary-content { cursor: default; } -.emotion-50 .header, -.emotion-50 .body .MuiAccordionSummary-content { +.emotion-51 .header, +.emotion-51 .body .MuiAccordionSummary-content { display: grid; grid-column-gap: 16px; grid-template-columns: 0.5fr 1fr 2fr 2fr 2fr; } -.emotion-51 { +.emotion-52 { padding: 16px; } -.emotion-51.columns-4 { +.emotion-52.columns-4 { grid-template-columns: 0.5fr 1fr 2fr 2fr; } -.emotion-52 { +.emotion-53 { background-color: #fff; color: rgba(10, 10, 11, 0.78); -webkit-transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; @@ -924,7 +921,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- background-color: #d4e9e7; } -.emotion-52:before { +.emotion-53:before { position: absolute; left: 0; top: -1px; @@ -937,62 +934,62 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-52:first-of-type:before { +.emotion-53:first-of-type:before { display: none; } -.emotion-52.Mui-expanded:before { +.emotion-53.Mui-expanded:before { opacity: 0; } -.emotion-52.Mui-expanded:first-of-type { +.emotion-53.Mui-expanded:first-of-type { margin-top: 0; } -.emotion-52.Mui-expanded:last-of-type { +.emotion-53.Mui-expanded:last-of-type { margin-bottom: 0; } -.emotion-52.Mui-expanded+.emotion-52.Mui-expanded:before { +.emotion-53.Mui-expanded+.emotion-53.Mui-expanded:before { display: none; } -.emotion-52.Mui-disabled { +.emotion-53.Mui-disabled { background-color: rgba(0, 0, 0, 0.12); } -.emotion-52.Mui-expanded { +.emotion-53.Mui-expanded { margin: 16px 0; } -.emotion-52:before { +.emotion-53:before { display: none; } -.emotion-52.Mui-expanded { +.emotion-53.Mui-expanded { margin: auto; background-color: #f7f7f7; } -.emotion-52:before { +.emotion-53:before { display: none; } -.emotion-52$expanded { +.emotion-53$expanded { margin: auto; } -.emotion-52 .columns-4 .MuiAccordionSummary-content { +.emotion-53 .columns-4 .MuiAccordionSummary-content { grid-template-columns: 0.5fr 1fr 2fr 2fr; } -.emotion-52 .MuiAccordionDetails-root { +.emotion-53 .MuiAccordionDetails-root { -webkit-flex-direction: row; -ms-flex-direction: row; flex-direction: row; } -.emotion-53 { +.emotion-54 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1037,44 +1034,44 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- height: 48px; } -.emotion-53::-moz-focus-inner { +.emotion-54::-moz-focus-inner { border-style: none; } -.emotion-53.Mui-disabled { +.emotion-54.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-53 { + .emotion-54 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-53.Mui-focusVisible { +.emotion-54.Mui-focusVisible { background-color: rgba(0, 0, 0, 0.12); } -.emotion-53.Mui-disabled { +.emotion-54.Mui-disabled { opacity: 0.38; } -.emotion-53:hover:not(.Mui-disabled) { +.emotion-54:hover:not(.Mui-disabled) { cursor: pointer; } -.emotion-53.Mui-expanded { +.emotion-54.Mui-expanded { min-height: 64px; } -.emotion-53.Mui-expanded { +.emotion-54.Mui-expanded { height: 48px; min-height: 48px; } -.emotion-54 { +.emotion-55 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1092,19 +1089,19 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- align-items: center; } -.emotion-54.Mui-expanded { +.emotion-55.Mui-expanded { margin: 20px 0; } -.emotion-54.Mui-expanded { +.emotion-55.Mui-expanded { margin: 0; } -.emotion-54>:last-child { +.emotion-55>:last-child { padding-right: 12px; } -.emotion-55 { +.emotion-56 { height: 0; overflow: hidden; -webkit-transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; @@ -1112,7 +1109,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- visibility: hidden; } -.emotion-56 { +.emotion-57 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1120,18 +1117,18 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- width: 100%; } -.emotion-57 { +.emotion-58 { width: 100%; } -.emotion-58 { +.emotion-59 { padding: 8px 16px 16px; -webkit-flex-direction: column; -ms-flex-direction: column; flex-direction: column; } -.emotion-59 { +.emotion-60 { -webkit-box-pack: end; -ms-flex-pack: end; -webkit-justify-content: flex-end; @@ -1139,7 +1136,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- margin-top: 16px; } -.emotion-60 { +.emotion-61 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1189,47 +1186,47 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- padding: 10px 15px; } -.emotion-60::-moz-focus-inner { +.emotion-61::-moz-focus-inner { border-style: none; } -.emotion-60.Mui-disabled { +.emotion-61.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-60 { + .emotion-61 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-60:hover { +.emotion-61:hover { -webkit-text-decoration: none; text-decoration: none; background-color: rgba(93, 15, 67, 0.04); } @media (hover: none) { - .emotion-60:hover { + .emotion-61:hover { background-color: transparent; } } -.emotion-60.Mui-disabled { +.emotion-61.Mui-disabled { color: rgba(0, 0, 0, 0.26); } -.emotion-60:hover { +.emotion-61:hover { colors: #337a87; } -.emotion-60.MuiButton-text { +.emotion-61.MuiButton-text { color: rgba(10, 10, 11, 0.78); } -.emotion-62 { +.emotion-63 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1247,7 +1244,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- min-width: 240px; } -.emotion-63 { +.emotion-64 { line-height: 1.4375em; font-family: Lato,sans-serif; font-weight: 400; @@ -1267,16 +1264,16 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-37:focus::-ms-input- position: relative; } -.emotion-63.Mui-disabled { +.emotion-64.Mui-disabled { color: rgba(0, 0, 0, 0.38); cursor: default; } -label+.emotion-63 { +label+.emotion-64 { margin-top: 16px; } -.emotion-63:after { +.emotion-64:after { border-bottom: 2px solid #337a87; left: 0; bottom: 0; @@ -1292,19 +1289,19 @@ label+.emotion-63 { pointer-events: none; } -.emotion-63.Mui-focused:after { +.emotion-64.Mui-focused:after { -webkit-transform: scaleX(1) translateX(0); -moz-transform: scaleX(1) translateX(0); -ms-transform: scaleX(1) translateX(0); transform: scaleX(1) translateX(0); } -.emotion-63.Mui-error:before, -.emotion-63.Mui-error:after { +.emotion-64.Mui-error:before, +.emotion-64.Mui-error:after { border-bottom-color: #ab1000; } -.emotion-63:before { +.emotion-64:before { border-bottom: 1px solid rgba(0, 0, 0, 0.42); left: 0; bottom: 0; @@ -1316,33 +1313,33 @@ label+.emotion-63 { pointer-events: none; } -.emotion-63:hover:not(.Mui-disabled, .Mui-error):before { +.emotion-64:hover:not(.Mui-disabled, .Mui-error):before { border-bottom: 2px solid rgba(10, 10, 11, 0.78); } @media (hover: none) { - .emotion-63:hover:not(.Mui-disabled, .Mui-error):before { + .emotion-64:hover:not(.Mui-disabled, .Mui-error):before { border-bottom: 1px solid rgba(0, 0, 0, 0.42); } } -.emotion-63.Mui-disabled:before { +.emotion-64.Mui-disabled:before { border-bottom-style: dotted; } -.emotion-63:before { +.emotion-64:before { border-bottom: 1px solid rgb(224, 224, 224); } -.emotion-63:hover:not($disabled):before { +.emotion-64:hover:not($disabled):before { border-bottom: 2px solid #337a87!important; } -.emotion-63:after { +.emotion-64:after { border-bottom: 2px solid #337a87; } -.emotion-68 { +.emotion-69 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1396,59 +1393,59 @@ label+.emotion-63 { background-color: #5d0f43; } -.emotion-68::-moz-focus-inner { +.emotion-69::-moz-focus-inner { border-style: none; } -.emotion-68.Mui-disabled { +.emotion-69.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-68 { + .emotion-69 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-68:active { +.emotion-69:active { box-shadow: 0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12); } -.emotion-68:hover { +.emotion-69:hover { background-color: #f5f5f5; -webkit-text-decoration: none; text-decoration: none; } @media (hover: none) { - .emotion-68:hover { + .emotion-69:hover { background-color: #e6f2f1; } } -.emotion-68.Mui-focusVisible { +.emotion-69.Mui-focusVisible { box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12); } -.emotion-68:hover { +.emotion-69:hover { background-color: rgb(65, 10, 46); } @media (hover: none) { - .emotion-68:hover { + .emotion-69:hover { background-color: #5d0f43; } } -.emotion-68.Mui-disabled { +.emotion-69.Mui-disabled { color: rgba(0, 0, 0, 0.26); box-shadow: none; background-color: rgba(0, 0, 0, 0.12); } -.emotion-70 { +.emotion-71 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1499,23 +1496,23 @@ label+.emotion-63 { font-weight: bold; } -.emotion-70::-moz-focus-inner { +.emotion-71::-moz-focus-inner { border-style: none; } -.emotion-70.Mui-disabled { +.emotion-71.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-70 { + .emotion-71 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-70:hover { +.emotion-71:hover { -webkit-text-decoration: none; text-decoration: none; background-color: rgb(35, 85, 94); @@ -1523,34 +1520,34 @@ label+.emotion-63 { } @media (hover: none) { - .emotion-70:hover { + .emotion-71:hover { background-color: #337a87; } } -.emotion-70:active { +.emotion-71:active { box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); } -.emotion-70.Mui-focusVisible { +.emotion-71.Mui-focusVisible { box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12); } -.emotion-70.Mui-disabled { +.emotion-71.Mui-disabled { color: rgba(0, 0, 0, 0.26); box-shadow: none; background-color: rgba(0, 0, 0, 0.12); } -.emotion-70:hover { +.emotion-71:hover { colors: #337a87; } -.emotion-70.MuiButton-text { +.emotion-71.MuiButton-text { color: rgba(10, 10, 11, 0.78); } -.emotion-72 { +.emotion-73 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1600,47 +1597,47 @@ label+.emotion-63 { padding: 10px 15px; } -.emotion-72::-moz-focus-inner { +.emotion-73::-moz-focus-inner { border-style: none; } -.emotion-72.Mui-disabled { +.emotion-73.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-72 { + .emotion-73 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-72:hover { +.emotion-73:hover { -webkit-text-decoration: none; text-decoration: none; background-color: rgba(51, 122, 135, 0.04); } @media (hover: none) { - .emotion-72:hover { + .emotion-73:hover { background-color: transparent; } } -.emotion-72.Mui-disabled { +.emotion-73.Mui-disabled { color: rgba(0, 0, 0, 0.26); } -.emotion-72:hover { +.emotion-73:hover { colors: #337a87; } -.emotion-72.MuiButton-text { +.emotion-73.MuiButton-text { color: rgba(10, 10, 11, 0.78); } -.emotion-74 { +.emotion-75 { position: fixed; bottom: 52px; right: 52px; @@ -1649,13 +1646,13 @@ label+.emotion-63 { pointer-events: none; } -.emotion-74 .MuiSpeedDialAction-staticTooltipLabel { +.emotion-75 .MuiSpeedDialAction-staticTooltipLabel { min-width: -webkit-max-content; min-width: -moz-max-content; min-width: max-content; } -.emotion-75 { +.emotion-76 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1670,12 +1667,12 @@ label+.emotion-63 { justify-content: flex-end; } -.emotion-76 { +.emotion-77 { margin-right: 16px; margin-bottom: 32px; } -.emotion-77 { +.emotion-78 { z-index: 1050; display: -webkit-box; display: -webkit-flex; @@ -1692,7 +1689,7 @@ label+.emotion-63 { margin: 16px; } -.emotion-77 .MuiSpeedDial-actions { +.emotion-78 .MuiSpeedDial-actions { -webkit-flex-direction: column-reverse; -ms-flex-direction: column-reverse; flex-direction: column-reverse; @@ -1700,7 +1697,7 @@ label+.emotion-63 { padding-bottom: 48px; } -.emotion-78 { +.emotion-79 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1755,68 +1752,68 @@ label+.emotion-63 { pointer-events: auto; } -.emotion-78::-moz-focus-inner { +.emotion-79::-moz-focus-inner { border-style: none; } -.emotion-78.Mui-disabled { +.emotion-79.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-78 { + .emotion-79 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-78:active { +.emotion-79:active { box-shadow: 0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12); } -.emotion-78:hover { +.emotion-79:hover { background-color: #f5f5f5; -webkit-text-decoration: none; text-decoration: none; } @media (hover: none) { - .emotion-78:hover { + .emotion-79:hover { background-color: #e6f2f1; } } -.emotion-78.Mui-focusVisible { +.emotion-79.Mui-focusVisible { box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12); } -.emotion-78:hover { +.emotion-79:hover { background-color: rgb(35, 85, 94); } @media (hover: none) { - .emotion-78:hover { + .emotion-79:hover { background-color: #337a87; } } -.emotion-78.Mui-disabled { +.emotion-79.Mui-disabled { color: rgba(0, 0, 0, 0.26); box-shadow: none; background-color: rgba(0, 0, 0, 0.12); } -.emotion-79 { +.emotion-80 { height: 24px; } -.emotion-79 .MuiSpeedDialIcon-icon { +.emotion-80 .MuiSpeedDialIcon-icon { -webkit-transition: -webkit-transform 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-79 .MuiSpeedDialIcon-openIcon { +.emotion-80 .MuiSpeedDialIcon-openIcon { position: absolute; -webkit-transition: -webkit-transform 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; @@ -1827,7 +1824,7 @@ label+.emotion-63 { transform: rotate(-45deg); } -.emotion-82 { +.emotion-83 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1837,7 +1834,7 @@ label+.emotion-63 { transition: top 0s linear 0.2s; } -.emotion-83 { +.emotion-84 { position: relative; display: -webkit-box; display: -webkit-flex; @@ -1849,7 +1846,7 @@ label+.emotion-63 { align-items: center; } -.emotion-83 .MuiSpeedDialAction-staticTooltipLabel { +.emotion-84 .MuiSpeedDialAction-staticTooltipLabel { -webkit-transition: -webkit-transform 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; opacity: 0; @@ -1862,7 +1859,7 @@ label+.emotion-63 { margin-right: 8px; } -.emotion-84 { +.emotion-85 { position: absolute; line-height: 1.5; font-family: Lato,sans-serif; @@ -1876,7 +1873,7 @@ label+.emotion-63 { word-break: keep-all; } -.emotion-85 { +.emotion-86 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1938,53 +1935,53 @@ label+.emotion-63 { transform: scale(0); } -.emotion-85::-moz-focus-inner { +.emotion-86::-moz-focus-inner { border-style: none; } -.emotion-85.Mui-disabled { +.emotion-86.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-85 { + .emotion-86 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-85:active { +.emotion-86:active { box-shadow: 0px 7px 8px -4px rgba(0,0,0,0.2),0px 12px 17px 2px rgba(0,0,0,0.14),0px 5px 22px 4px rgba(0,0,0,0.12); } -.emotion-85:hover { +.emotion-86:hover { background-color: #f5f5f5; -webkit-text-decoration: none; text-decoration: none; } @media (hover: none) { - .emotion-85:hover { + .emotion-86:hover { background-color: #e6f2f1; } } -.emotion-85.Mui-focusVisible { +.emotion-86.Mui-focusVisible { box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12); } -.emotion-85.Mui-disabled { +.emotion-86.Mui-disabled { color: rgba(0, 0, 0, 0.26); box-shadow: none; background-color: rgba(0, 0, 0, 0.12); } -.emotion-85:hover { +.emotion-86:hover { background-color: rgb(216, 216, 216); } -.emotion-101 { +.emotion-102 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -2001,7 +1998,7 @@ label+.emotion-63 { font-size: inherit; } -.emotion-101 iconButton { +.emotion-102 iconButton { margin-right: 8px; } @@ -2313,21 +2310,27 @@ label+.emotion-63 { class="MuiInputAdornment-root MuiInputAdornment-positionEnd emotion-38" > @@ -443,7 +467,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-2:focus::-ms-input-p @@ -959,7 +1007,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-2:focus::-ms-input-p
-
-
+
+ - +
+ Edit + + @@ -1171,11 +1087,11 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-16:focus::-ms-input-
@@ -1229,7 +1145,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-16:focus::-ms-input- style="max-width: fit-content;" >
+ + - +
+ Edit + + @@ -833,7 +751,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-6:focus::-ms-input-p >
-
+
+ Release name +
- - ​ - -
+ +
+
+
+
+
+ Tags +
+
+
+
-
-
-
+
+
+ Contains Artifact type +
+
+
+
+ +
+ +
+
+
+
+
+

+ +
Name Number of artifacts + Tags + Last modified
r1 1 + - +

Rows