Skip to content

Commit

Permalink
feat: Logs
Browse files Browse the repository at this point in the history
Added Logs component so erm-comparisons and local-kb-admin have fewer shared components. Also swapped LogList pagination over to prev-next pagination

ERM-3045
  • Loading branch information
EthanFreestone committed Oct 3, 2023
1 parent c83e5c5 commit 5ec3781
Show file tree
Hide file tree
Showing 11 changed files with 368 additions and 37 deletions.
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as ViewOrganizationCard } from './lib/ViewOrganizationCard';
export { default as LicenseCard } from './lib/LicenseCard';
export { default as LicenseEndDate } from './lib/LicenseEndDate';
export { default as LogsList } from './lib/LogsList';
export { default as Logs } from './lib/Logs';
export { default as OrganizationSelection } from './lib/OrganizationSelection';
export { default as SerialCoverage } from './lib/SerialCoverage';
export { default as Tags } from './lib/Tags';
Expand Down
118 changes: 118 additions & 0 deletions lib/Logs/Logs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';

import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';

import { generateKiwtQueryParams } from '@k-int/stripes-kint-components';

import { useOkapiKy } from '@folio/stripes/core';
import { Accordion, Badge, Button, Spinner } from '@folio/stripes/components';

import { useExportLogStream, usePrevNextPagination } from '../hooks';
import LogsList from '../LogsList';

const RESULT_COUNT_INCREMENT = 100;

const Logs = ({
allowExport = false,
id,
job,
type
}) => {
const ky = useOkapiKy();
const logCount = job[`${type}LogCount`];

const LOGS_ENDPOINT = `erm/jobs/${job.id}/${type}Log`;

const { refetch: fetchLogs, isLoading } = useExportLogStream(job, type);

const [count, setCount] = useState(0);

const { currentPage, paginationMCLProps } = usePrevNextPagination({
count,
pageSize: RESULT_COUNT_INCREMENT,
syncToLocation: false
});

// LOGS INFINITE FETCH
const logQueryParams = useMemo(() => (
generateKiwtQueryParams(
{
page: currentPage,
perPage: RESULT_COUNT_INCREMENT
},
{}
)
), [currentPage]);

const {
data: { results: logs = [], totalRecords: logsCount = 0 } = {},
isLoading: areLogsLoading,
} = useQuery(
['ERM', 'Job', job.id, 'Logs', type, LOGS_ENDPOINT, logQueryParams],
() => {
const params = [...logQueryParams];
return ky.get(`${LOGS_ENDPOINT}?${params?.join('&')}`).json();
},
);

// Bit hacky, gets us around the "used before defined" problem
useEffect(() => {
if (count !== logsCount) {
setCount(logsCount);
}
}, [count, logsCount]);

const renderBadgeAndExport = useCallback(() => {
return (
<>
<div data-testid="logs">
{allowExport &&
<Button
disabled={logCount < 1 || isLoading}
onClick={() => fetchLogs()}
>
<FormattedMessage id="stripes-erm-components.export" />
{
isLoading &&
<Spinner />
}
</Button>
}
<Badge>
{logCount}
</Badge>
</div>
</>
);
}, [allowExport, fetchLogs, isLoading, logCount]);

return (
<Accordion
displayWhenClosed={renderBadgeAndExport()}
displayWhenOpen={renderBadgeAndExport()}
id={id}
label={<FormattedMessage id={`stripes-erm-components.${type}Log`} />}
>
<LogsList
job={job}
loading={areLogsLoading}
logs={logs}
mclProps={{
...paginationMCLProps,
}}
type={type}
/>
</Accordion>
);
};

Logs.propTypes = {
allowExport: PropTypes.bool,
id: PropTypes.string,
job: PropTypes.object,
type: PropTypes.string.isRequired,
};

export default Logs;
124 changes: 124 additions & 0 deletions lib/Logs/Logs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { MemoryRouter } from 'react-router-dom';

import { Accordion, Button, renderWithIntl } from '@folio/stripes-erm-testing';
import userEvent from '@folio/jest-config-stripes/testing-library/user-event';

import { translationsProperties } from '../../test/jest/helpers';
import Logs from './Logs';

const job = {
'id': '4fba2eea-8e86-41a3-a00d-a6fa8cc68239',
'dateCreated': 1670384664063,
'ended': 1670384668945,
'result': {
'id': '2c91809c84e7ad0e0184e7b4d53d0024',
'value': 'success',
'label': 'Success'
},
'name': 'Scheduled Package Ingest Job 2022-12-07T03:44:24.060981Z',
'started': 1670384668275,
'status': {
'id': '2c91809c84e7ad0e0184e7b4d556002b',
'value': 'ended',
'label': 'Ended'
},
'class': 'org.olf.general.jobs.PackageIngestJob',
'fullLogCount': 1,
'errorLogCount': 0,
'infoLogCount': 1
};

describe('Logs', () => {
let renderComponent;
describe('Error log', () => {
beforeEach(() => {
renderComponent = renderWithIntl(
<MemoryRouter>
<Logs
id="errorLogs"
job={job}
type="error"
/>
</MemoryRouter>,
translationsProperties
);
});

test('renders the Error log Accordion', async () => {
await Accordion('Error log').exists();
});

it('renders error message for the job', () => {
const { getByText } = renderComponent;
expect(getByText('No errors to report.')).toBeInTheDocument();
});
});

describe('Info log', () => {
beforeEach(() => {
renderComponent = renderWithIntl(
<MemoryRouter>
<Logs
id="infoLogs"
job={job}
type="info"
/>
</MemoryRouter>,
translationsProperties
);
});

test('renders the Info log Accordion', async () => {
await Accordion('Info log').exists();
});

it('renders information message for the job', () => {
const { getByText } = renderComponent;
expect(getByText('No information notices for this job.')).toBeInTheDocument();
});
});

describe('Export behaviour without allowExport', () => {
beforeEach(() => {
renderComponent = renderWithIntl(
<MemoryRouter>
<Logs
id="infoLogs"
job={job}
type="info"
/>
</MemoryRouter>,
translationsProperties
);
});
test('does not render the Export button', async () => {
await Button('Export').absent();
});
});

describe('Export behaviour with allowExport', () => {
beforeEach(() => {
renderComponent = renderWithIntl(
<MemoryRouter>
<Logs
allowExport
id="infoLogs"
job={job}
type="info"
/>
</MemoryRouter>,
translationsProperties
);
});
test('renders the Export button', async () => {
await Button('Export').exists();
});

it('export button works as expected', () => {
const { getByRole, queryAllByTestId } = renderComponent;
expect(getByRole('button', { name: 'Export' })).toBeInTheDocument();
userEvent.click(getByRole('button', { name: 'Export' }));
expect(queryAllByTestId(/logs\[.*\]/i).length).toEqual(0);
});
});
});
1 change: 1 addition & 0 deletions lib/Logs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Logs';
24 changes: 20 additions & 4 deletions lib/LogsList/LogsList.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ const RESULT_COUNT_INCREMENT = 100;
export default class LogsList extends React.Component {
static propTypes = {
job: PropTypes.object,
loading: PropTypes.bool,
logs: PropTypes.arrayOf(PropTypes.shape({
recordDescriptor: PropTypes.string,
message: PropTypes.string,
})),
onNeedMoreLogs: PropTypes.func.isRequired,
mclProps: PropTypes.shape({
onNeedMoreData: PropTypes.func,
pagingCanGoNext: PropTypes.bool,
pagingCanGoPrevious: PropTypes.bool,
pagingOffset: PropTypes.number,
}),
onNeedMoreLogs: PropTypes.func,
type: PropTypes.string.isRequired,
};

Expand All @@ -31,10 +38,17 @@ export default class LogsList extends React.Component {
}

render() {
const { job, logs, onNeedMoreLogs, type } = this.props;
const {
job,
loading,
logs,
mclProps, // If provided, these will overwrite everything else
onNeedMoreLogs,
type
} = this.props;

if (!logs) return <Spinner />;
if (!logs.length) return <FormattedMessage id={`stripes-erm-components.${type}LogNo`} />;
if (loading || !logs) return <Spinner />;
if (!loading && !logs.length) return <FormattedMessage id={`stripes-erm-components.${type}LogNo`} />;

return (
<MultiColumnList
Expand All @@ -46,13 +60,15 @@ export default class LogsList extends React.Component {
formatter={this.resultsFormatter}
id={`list-${type}Log`}
interactive={false}
loading={loading}
maxHeight={800}
onNeedMoreData={onNeedMoreLogs}
pageAmount={RESULT_COUNT_INCREMENT}
pagingType="click"
totalCount={job[`${type}LogCount`]}
virtualize
visibleColumns={['recordDescriptor', 'message']}
{...mclProps}
/>
);
}
Expand Down
1 change: 1 addition & 0 deletions lib/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export { default as useParallelBatchFetch } from './useParallelBatchFetch';
export { default as usePrevNextPagination } from './usePrevNextPagination';
export { default as usePrevious } from './usePrevious';
export { default as useSASQQIndex } from './useSASQQIndex';
export { default as useExportLogStream } from './useExportLogStream';
17 changes: 17 additions & 0 deletions lib/hooks/useExportLogStream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# useExportLogStream
This is a small hook which allows for the fetch and download of Log streams from mod-agreements.

## API
useExportLogStream is called with parameters `job`, of type `object` and `type` of type `string`.

### Job
The parameter `job` is expected to contain at a minimum the properties `id` and `name`. It will use the `id` for fetching and the `name` for the eventual downloaded file.

### Type
The parameter `type` is a string referring to the "type" of logs to fetch from mod-agreements, namely
- error
- info
- full

## Result
The hook will fetch the data, and when available download it from the user's browser. It will also handle the triggering of a callout informing the user that said download may take a minute or so.
1 change: 1 addition & 0 deletions lib/hooks/useExportLogStream/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './useExportLogStream';
36 changes: 36 additions & 0 deletions lib/hooks/useExportLogStream/useExportLogStream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useCallback, useContext } from 'react';
import { FormattedMessage } from 'react-intl';
import { useQuery } from 'react-query';

import { CalloutContext, useOkapiKy } from '@folio/stripes/core';

import downloadBlob from '../../downloadBlob';

const useExportLogStream = (job, type) => {
const callout = useContext(CalloutContext);
const ky = useOkapiKy();
const { refetch, isLoading } = useQuery(
['ui-local-kb-admin', 'Logs', 'fetchLogStream', job?.id, type],
() => ky(
`erm/jobs/${job?.id}/${type}LogStream`
).blob()
.then(downloadBlob(`${job?.name}:${type}`, { fileExt: 'json' })),
{
// Ensure this doesn't run until the user clicks the button
enabled: false
}
);

const refetchWithCallout = useCallback(() => {
callout.sendCallout({ message: <FormattedMessage id="ui-local-kb-admin.job.log.export.creation" /> });
refetch();
}, [callout, refetch]);

if (!job || !type) {
return {};
}

return { refetch: refetchWithCallout, isLoading };
};

export default useExportLogStream;
Loading

0 comments on commit 5ec3781

Please sign in to comment.