Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display user's shared links table + Add new links #64

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="nextauth_secret"
NEXT_CONTAINER_KEYCLOAK_ENDPOINT="http://keycloak:8080"
NEXT_LOCAL_KEYCLOAK_URL="http://localhost:8080"
NEXT_PUBLIC_API_URL=${NEXTAUTH_URL}/api/v1

SHLP_POSTGRES_HOST=postgres
SHLP_POSTGRES_DB=shlp
Expand Down
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="nextauth_secret"
NEXT_CONTAINER_KEYCLOAK_ENDPOINT="http://keycloak:8080"
NEXT_LOCAL_KEYCLOAK_URL="http://localhost:8080"
NEXT_PUBLIC_API_URL=${NEXTAUTH_URL}/api/v1

SHLP_POSTGRES_HOST=postgres
SHLP_POSTGRES_DB=shlp
Expand Down
4 changes: 4 additions & 0 deletions envConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { loadEnvConfig } from '@next/env';

const projectDir = process.cwd();
loadEnvConfig(projectDir);
Comment on lines +1 to +4
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add error handling and logging.

While the current implementation is functional, it doesn't handle potential errors or provide any logging. This could make debugging issues related to environment configuration more difficult.

Consider adding error handling and logging:

import { loadEnvConfig } from '@next/env';

const projectDir = process.cwd();

try {
  const { combinedEnv, loadedEnvFiles } = loadEnvConfig(projectDir);
  console.log('Environment configuration loaded successfully');
  console.log('Loaded env files:', loadedEnvFiles);
  
  // You can add more detailed logging here if needed
  // For example, logging specific environment variables (be careful not to log sensitive information)
  // console.log('API_URL:', combinedEnv.API_URL);

  export { combinedEnv, loadedEnvFiles };
} catch (error) {
  console.error('Failed to load environment configuration:', error);
  // You might want to throw the error here or handle it in some other way
  throw error;
}

This modification adds error handling and basic logging, which can help with debugging and provide more information about the environment configuration loading process.


LGTM! Consider exporting the loaded configuration.

The implementation looks correct. It imports the necessary function, gets the current working directory, and loads the environment configuration. However, you might want to consider exporting the result of loadEnvConfig for use in other parts of the application.

Consider modifying the code as follows:

 import { loadEnvConfig } from '@next/env';

 const projectDir = process.cwd();
-loadEnvConfig(projectDir);
+export const { combinedEnv, loadedEnvFiles } = loadEnvConfig(projectDir);

This change would allow other parts of your application to import and use the loaded environment configuration.

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { loadEnvConfig } from '@next/env';
const projectDir = process.cwd();
loadEnvConfig(projectDir);
import { loadEnvConfig } from '@next/env';
const projectDir = process.cwd();
export const { combinedEnv, loadedEnvFiles } = loadEnvConfig(projectDir);

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@
"@mui/material": "^5.16.6",
"@mui/material-nextjs": "^5.16.6",
"@types/fhir": "^0.0.41",
"axios": "^1.7.5",
"axios": "^1.7.7",
"next": "14.2.5",
"next-auth": "^4.24.7",
"next-swagger-doc": "^0.4.0",
"prisma-field-encryption": "^1.5.2",
"qrcode": "^1.5.4",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.53.0",
"winston-daily-rotate-file": "^5.0.0",
"swagger-ui-react": "^5.17.14",
"uuid": "^10.0.0",
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export default async function Header() {
>
Patient Summary View
</MenuItem>
<MenuItem key="dashboard" component={NextLink} href="/shared-links">
Dashboard
</MenuItem>
<Box sx={{ width: '100%', textAlign: 'right' }}>{AuthButton}</Box>
</Toolbar>
</AppBar>
Expand Down
19 changes: 19 additions & 0 deletions src/app/hooks/useSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client';
import { type Session } from 'next-auth';
import { getSession } from 'next-auth/react';
import { useCallback, useEffect, useState } from 'react';

export const useSession = () => {
const [session, setSession] = useState<Session>();

const fetchSession = useCallback(async () => {
const serverSession = await getSession();
if (serverSession) setSession(serverSession);
}, []);

useEffect(() => {
fetchSession();
}, [fetchSession]);

return session;
};
138 changes: 138 additions & 0 deletions src/app/shared-links/Components/AddLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use client';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
styled,
} from '@mui/material';
import { FC, useEffect } from 'react';
import { useForm } from 'react-hook-form';

import { useSession } from '@/app/hooks/useSession';
import { apiSharedLink } from '@/app/utils/api.class';
import { CreateSHLinkDto } from '@/domain/dtos/shlink';

const removeUndefinedValues = <T extends Record<string, unknown>>(
object: T,
): T => JSON.parse(JSON.stringify(object));

const StyledDialogTitle = styled(DialogTitle)(() => ({
backgroundImage: 'linear-gradient(to bottom, hsla(0, 0%, 90%, .05), #e6e6e6)',
}));

const StyledDialogContent = styled('div')(() => ({
gap: '15px',
margin: '15px',
display: 'flex',
flexDirection: 'column',
}));

const StyledDialogActions = styled(DialogActions)(() => ({
paddingTop: '15px',
paddingRight: '25px',
paddingBottom: '15px',
backgroundImage: 'linear-gradient(to top, hsla(0, 0%, 90%, .05), #e6e6e6)',
}));

export type TCreateSHLinkDto = Omit<CreateSHLinkDto, 'configExp'> & {
configExp?: string;
};

interface AddLinkDialogProps {
open?: boolean;
onClose?: () => void;
callback?: () => void;
}

export const AddLinkDialog: FC<AddLinkDialogProps> = ({
open,
onClose,
callback,
}) => {
const data = useSession();
const {
reset,
register,
formState: { errors },
resetField,
handleSubmit,
} = useForm<TCreateSHLinkDto>();

const onSubmitForm = async (data: TCreateSHLinkDto) => {
try {
const transformedData = removeUndefinedValues(data);
await apiSharedLink.createLink(transformedData);
callback?.();
} catch (error) {
console.error('Failed to create link:', error);
}
};

useEffect(() => {
if (open) reset();
}, [open]);

useEffect(() => {
if (data?.token?.sub)
resetField('userId', { defaultValue: data.token.sub });
}, [data?.token?.sub]);

return (
<Dialog open={!!open} fullWidth maxWidth="xs" onClose={() => onClose?.()}>
<StyledDialogTitle>Create a new Link</StyledDialogTitle>
<DialogContent style={{ padding: '5px 8px' }}>
<form onSubmit={handleSubmit(onSubmitForm)}>
<StyledDialogContent>
<TextField
label="User id"
error={!!errors.userId}
disabled
required
inputProps={{ readOnly: true }}
{...register('userId', {})}
/>
<TextField
label="Name"
error={!!errors.name}
required
helperText={errors.name ? errors.name.message : null}
placeholder="Name"
{...register('name', { required: 'Required field' })}
/>
<TextField
type="number"
label="PIN Code"
{...register('configPasscode', {
setValueAs: (value) => value || undefined,
})}
/>
<TextField
type="date"
label="Expiration Date"
InputLabelProps={{ shrink: true }}
{...register('configExp', {
setValueAs: (value) => value || undefined,
})}
/>
</StyledDialogContent>
</form>
</DialogContent>
<StyledDialogActions>
<Button color="inherit" variant="contained" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
color="success"
variant="contained"
onClick={handleSubmit(onSubmitForm)}
>
Create
</Button>
</StyledDialogActions>
</Dialog>
);
Comment on lines +83 to +137
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhance accessibility for form inputs

The form inputs could benefit from improved accessibility attributes.

Consider adding aria-describedby attributes to connect error messages with their respective inputs:

<TextField
  label="Name"
  error={!!errors.name}
  required
  helperText={errors.name ? errors.name.message : null}
  placeholder="Name"
  aria-describedby={errors.name ? "name-error" : undefined}
  {...register('name', { required: 'Required field' })}
/>
{errors.name && <span id="name-error" style={{ display: 'none' }}>{errors.name.message}</span>}

Apply similar changes to other form inputs to improve screen reader support.

};
15 changes: 15 additions & 0 deletions src/app/shared-links/Components/BooleanIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import CancelIcon from '@mui/icons-material/Cancel';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { green, red } from '@mui/material/colors';

export default function BooleanIcon({
status,
}: {
status: boolean;
}): JSX.Element {
return status ? (
<CheckCircleIcon style={{ color: green[500] }} />
) : (
<CancelIcon style={{ color: red[500] }} />
);
}
150 changes: 150 additions & 0 deletions src/app/shared-links/Components/LinksTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use client';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import {
Button,
Grid,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
} from '@mui/material';
import { useEffect, useState } from 'react';
import React from 'react';

import { useSession } from '@/app/hooks/useSession';
import { apiSharedLink } from '@/app/utils/api.class';
import { SHLinkMiniDto } from '@/domain/dtos/shlink';

import { AddLinkDialog } from './AddLinkDialog';
import BooleanIcon from './BooleanIcon';

interface Column {
id: keyof SHLinkMiniDto;
label: string;
minWidth?: number;
align?: 'right' | 'left' | 'center';
format?: (
value: SHLinkMiniDto[keyof SHLinkMiniDto],
) => string | React.JSX.Element;
}

const columns: readonly Column[] = [
{ id: 'name', label: 'Name', minWidth: 100 },
{
id: 'expiryDate',
label: 'Expiry Date',
minWidth: 80,
format: (value?: Date) =>
value?.toString()?.substring(0, 10) || (
<span
style={{ display: 'flex', alignItems: 'center', color: '#9e9e9e' }}
>
<CalendarTodayIcon fontSize="small" style={{ marginRight: '4px' }} />
Not Defined
</span>
),
},
{
id: 'passwordRequired',
label: 'Passcode enabled',
minWidth: 50,
format: (value?: boolean) => <BooleanIcon status={!!value} />,
},
];

export default function LinksTable() {
const session = useSession();
const [links, setLinks] = useState<SHLinkMiniDto[]>([]);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [addDialog, setAddDialog] = React.useState<boolean>();

const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};

useEffect(() => {
apiSharedLink.findLinks().then(({ data }) => setLinks(data));
}, []);

const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(Number(event.target.value));
setPage(0);
};

const handleCreateLink = (_event: unknown) => {
setAddDialog(true);
};

return (
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<AddLinkDialog
open={addDialog}
onClose={() => setAddDialog(false)}
callback={() => {
apiSharedLink.findLinks().then(({ data }) => {
setLinks(data);
setAddDialog(false);
});
}}
/>
<Grid container justifyContent="end">
<Grid item>
<Button variant="contained" onClick={handleCreateLink}>
Add new link
</Button>
</Grid>
</Grid>
<TableContainer sx={{ maxHeight: '50vh' }}>
<Table stickyHeader aria-label="sticky table">
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
style={{ minWidth: column.minWidth }}
>
{column.label}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{links
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<TableRow hover tabIndex={-1} key={row.id}>
{columns.map((column) => {
const value = row[column.id];
return (
<TableCell key={column.id} align={column.align}>
{column.format
? column.format(value)
: value?.toString()}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
count={links.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
);
}
19 changes: 19 additions & 0 deletions src/app/shared-links/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Box, Container } from '@mui/material';

import LinksTable from './Components/LinksTable';

export default async function SharedLinksPage() {
return (
<Container maxWidth={false}>
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
paddingTop={8}
paddingBottom={8}
></Box>
<LinksTable />
</Container>
);
}
Loading
Loading