LCOV - code coverage report
Current view: top level - src - ImageSearchModal.jsx Coverage Total Hit
Test: cockpit-podman Lines: 90.1 % 171 154
Test Date: 2025-05-21 17:35:03

            Line data    Source code
       1           43 : import React, { useState } from 'react';
       2              : 
       3              : import { Button } from "@patternfly/react-core/dist/esm/components/Button";
       4              : import { DataList, DataListCell, DataListItem, DataListItemCells, DataListItemRow } from "@patternfly/react-core/dist/esm/components/DataList";
       5              : import { Form, FormGroup } from "@patternfly/react-core/dist/esm/components/Form";
       6              : import { FormSelect, FormSelectOption } from "@patternfly/react-core/dist/esm/components/FormSelect";
       7              : import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
       8              : import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
       9              : import {
      10              :     Modal
      11              : } from '@patternfly/react-core/dist/esm/deprecated/components/Modal';
      12              : import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex";
      13              : import { ExclamationCircleIcon } from '@patternfly/react-icons';
      14              : import { useDialogs } from "dialogs.jsx";
      15              : 
      16              : import cockpit from 'cockpit';
      17              : import { EmptyStatePanel } from "cockpit-components-empty-state.tsx";
      18              : 
      19              : import { ErrorNotification } from './Notification.jsx';
      20              : import * as client from './client.js';
      21              : import rest from './rest.js';
      22              : import { fallbackRegistries, usePodmanInfo } from './util.js';
      23              : 
      24              : import './ImageSearchModal.css';
      25              : 
      26           43 : const _ = cockpit.gettext;
      27              : 
      28            1 : export const ImageSearchModal = ({ downloadImage, users }) => {
      29            1 :     const [searchInProgress, setSearchInProgress] = useState(false);
      30            1 :     const [searchFinished, setSearchFinished] = useState(false);
      31            1 :     const [imageIdentifier, setImageIdentifier] = useState('');
      32            1 :     const [imageList, setImageList] = useState([]);
      33            1 :     const [imageTag, setImageTag] = useState("");
      34            1 :     const [user, setUser] = useState(users[0]);
      35            1 :     const [selectedRegistry, setSelectedRegistry] = useState("");
      36            1 :     const [selected, setSelected] = useState("");
      37            1 :     const [dialogError, setDialogError] = useState("");
      38            1 :     const [dialogErrorDetail, setDialogErrorDetail] = useState("");
      39            1 :     const [typingTimeout, setTypingTimeout] = useState(null);
      40              : 
      41            1 :     let activeConnection = null;
      42            1 :     const { registries } = usePodmanInfo();
      43            1 :     const Dialogs = useDialogs();
      44              :     // Registries to use for searching
      45            0 :     const searchRegistries = registries.search && registries.length !== 0 ? registries.search : fallbackRegistries;
      46              : 
      47              :     // Don't use on selectedRegistry state variable for finding out the
      48              :     // registry to search in as with useState we can only call something after a
      49              :     // state update with useEffect but as onSearchTriggered also changes state we
      50              :     // can't use that so instead we pass the selected registry.
      51            1 :     const onSearchTriggered = (searchRegistry = "", forceSearch = false) => {
      52              :         // When search re-triggers close any existing active connection
      53            1 :         activeConnection = rest.connect(user.uid);
      54            1 :         if (activeConnection)
      55            1 :             activeConnection.close();
      56            1 :         setSearchFinished(false);
      57              : 
      58              :         // Do not call the SearchImage API if the input string  is not at least 2 chars,
      59              :         // unless Enter is pressed, which should force start the search.
      60              :         // The comparison was done considering the fact that we miss always one letter due to delayed setState
      61            1 :         if (imageIdentifier.length < 2 && !forceSearch)
      62            1 :             return;
      63              : 
      64            1 :         setSearchInProgress(true);
      65              : 
      66            1 :         let queryRegistries = searchRegistries;
      67            1 :         if (searchRegistry !== "") {
      68            1 :             queryRegistries = [searchRegistry];
      69            1 :         }
      70              :         // if a user searches for `docker.io/cockpit` let podman search in the user specified registry.
      71            0 :         if (imageIdentifier.includes('/')) {
      72            0 :             queryRegistries = [""];
      73            0 :         }
      74              : 
      75            1 :         const searches = queryRegistries.map(rr => {
      76            0 :             const registry = rr.length < 1 || rr[rr.length - 1] === "/" ? rr : rr + "/";
      77            1 :             return activeConnection.call({
      78            1 :                 method: "GET",
      79            1 :                 path: client.VERSION + "libpod/images/search",
      80            1 :                 body: "",
      81            1 :                 params: {
      82            1 :                     term: registry + imageIdentifier
      83            1 :                 }
      84            1 :             });
      85            1 :         });
      86              : 
      87            1 :         Promise.allSettled(searches)
      88            1 :                 .then(reply => {
      89            1 :                     if (reply) {
      90            1 :                         let results = [];
      91              : 
      92            1 :                         for (const result of reply) {
      93            1 :                             if (result.status === "fulfilled") {
      94            1 :                                 results = results.concat(JSON.parse(result.value));
      95            0 :                             } else {
      96            0 :                                 setDialogError(_("Failed to search for new images"));
      97            0 :                                 setDialogErrorDetail(result.reason ? cockpit.format(_("Failed to search for images: $0"), result.reason.message) : _("Failed to search for images."));
      98            0 :                             }
      99            1 :                         }
     100              : 
     101            0 :                         setImageList(results || []);
     102            1 :                         setSearchInProgress(false);
     103            1 :                         setSearchFinished(true);
     104            1 :                     }
     105            1 :                 });
     106            1 :     };
     107              : 
     108            1 :     const onKeyDown = (e) => {
     109            1 :         if (e.key != ' ') { // Space should not trigger search
     110            1 :             const forceSearch = e.key == 'Enter';
     111            0 :             if (forceSearch) {
     112            0 :                 e.preventDefault();
     113            0 :             }
     114              : 
     115              :             // Reset the timer, to make the http call after 250MS
     116            1 :             clearTimeout(typingTimeout);
     117            1 :             setTypingTimeout(setTimeout(() => onSearchTriggered(selectedRegistry, forceSearch), 250));
     118            1 :         }
     119            1 :     };
     120              : 
     121            1 :     const onToggleUser = ev => setUser(users.find(u => u.name === ev.currentTarget.value));
     122            1 :     const onDownloadClicked = () => {
     123            1 :         const selectedImageName = imageList[selected].Name;
     124            1 :         if (activeConnection)
     125            0 :             activeConnection.close();
     126            1 :         Dialogs.close();
     127            1 :         downloadImage(selectedImageName, imageTag, user.con);
     128            1 :     };
     129              : 
     130            1 :     const handleClose = () => {
     131            1 :         if (activeConnection)
     132            0 :             activeConnection.close();
     133            1 :         Dialogs.close();
     134            1 :     };
     135              : 
     136            1 :     return (
     137            1 :         <Modal isOpen className="podman-search"
     138            1 :                position="top" variant="large"
     139            1 :                onClose={handleClose}
     140            1 :                title={_("Search for an image")}
     141            1 :                footer={<>
     142            1 :                    <Form isHorizontal className="image-search-tag-form">
     143            1 :                        <FormGroup fieldId="image-search-tag" label={_("Tag")}>
     144            1 :                            <TextInput className="image-tag-entry"
     145            1 :                                   id="image-search-tag"
     146            1 :                                   type='text'
     147            1 :                                   placeholder="latest"
     148            1 :                                   value={imageTag || ''}
     149            1 :                                   onChange={(_event, value) => setImageTag(value)} />
     150            1 :                        </FormGroup>
     151            1 :                    </Form>
     152            1 :                    <Button variant='primary' isDisabled={selected === ""} onClick={onDownloadClicked}>
     153            1 :                        {_("Download")}
     154            1 :                    </Button>
     155            1 :                    <Button variant='link' className='btn-cancel' onClick={handleClose}>
     156            1 :                        {_("Cancel")}
     157            1 :                    </Button>
     158            1 :                </>}
     159              :         >
     160            1 :             <Form isHorizontal>
     161            0 :                 {dialogError && <ErrorNotification errorMessage={dialogError} errorDetail={dialogErrorDetail} />}
     162            1 :                 { users.length > 1 &&
     163            1 :                 <FormGroup id="as-user" label={_("Owner")} isInline>
     164            1 :                     { users.map(u => (
     165            1 :                         <Radio key={u.name}
     166            1 :                                value={u.name}
     167            1 :                                label={u.name}
     168            1 :                                id={"image-search-modal-owner-" + u.name}
     169            1 :                                onChange={onToggleUser}
     170            1 :                                isChecked={u === user} />))
     171              :                     }
     172            1 :                 </FormGroup>}
     173            1 :                 <Flex spaceItems={{ default: 'inlineFlex', modifier: 'spaceItemsXl' }}>
     174            1 :                     <FormGroup fieldId="search-image-dialog-name" label={_("Search for")}>
     175            1 :                         <TextInput id='search-image-dialog-name'
     176            1 :                                    type='text'
     177            1 :                                    placeholder={_("Search by name or description")}
     178            1 :                                    value={imageIdentifier}
     179            1 :                                    onKeyDown={onKeyDown}
     180            1 :                                    onChange={(_event, value) => setImageIdentifier(value)} />
     181            1 :                     </FormGroup>
     182            1 :                     <FormGroup fieldId="registry-select" label={_("in")}>
     183            1 :                         <FormSelect id='registry-select'
     184            1 :                             value={selectedRegistry}
     185            1 :                             onChange={(_ev, value) => { setSelectedRegistry(value); clearTimeout(typingTimeout); onSearchTriggered(value, false) }}>
     186            1 :                             <FormSelectOption value="" key="all" label={_("All registries")} />
     187            0 :                             {(searchRegistries || []).map(r => <FormSelectOption value={r} key={r} label={r} />)}
     188            1 :                         </FormSelect>
     189            1 :                     </FormGroup>
     190            1 :                 </Flex>
     191            1 :             </Form>
     192              : 
     193            1 :             {searchInProgress && <EmptyStatePanel loading title={_("Searching...")} /> }
     194              : 
     195            1 :             {((!searchInProgress && !searchFinished) || imageIdentifier == "") && <EmptyStatePanel title={_("No images found")} paragraph={_("Start typing to look for images.")} /> }
     196              : 
     197            1 :             {searchFinished && imageIdentifier !== '' && <>
     198            1 :                 {imageList.length == 0 && <EmptyStatePanel icon={ExclamationCircleIcon}
     199            1 :                                                                       title={cockpit.format(_("No results for $0"), imageIdentifier)}
     200            1 :                                                                       paragraph={_("Retry another term.")}
     201            1 :                 />}
     202            1 :                 {imageList.length > 0 &&
     203            1 :                 <DataList isCompact
     204            1 :                           selectedDataListItemId={"image-list-item-" + selected}
     205            1 :                           onSelectDataListItem={(_, key) => setSelected(key.split('-').slice(-1)[0])}>
     206            1 :                     {imageList.map((image, iter) => {
     207            1 :                         return (
     208            1 :                             <DataListItem id={"image-list-item-" + iter} key={iter}>
     209            1 :                                 <DataListItemRow>
     210            1 :                                     <DataListItemCells
     211            1 :                                               dataListCells={[
     212            1 :                                                   <DataListCell key="primary content">
     213            1 :                                                       <span className='image-name'>{image.Name}</span>
     214            1 :                                                   </DataListCell>,
     215            1 :                                                   <DataListCell key="secondary content" wrapModifier="truncate">
     216            1 :                                                       <span className='image-description'>{image.Description}</span>
     217            1 :                                                   </DataListCell>
     218            1 :                                               ]}
     219            1 :                                     />
     220            1 :                                 </DataListItemRow>
     221            1 :                             </DataListItem>
     222              :                         );
     223            1 :                     })}
     224            1 :                 </DataList>}
     225            1 :             </>}
     226            1 :         </Modal>
     227              :     );
     228            1 : };
        

Generated by: LCOV version 2.0-1