chore: improve CI and fix navigation (#352)

* chore: simple change to trigger new ci

* feat: make PR CD work

issue #338

* ci: fix target deploy url

* ci: improve deployments into just one matrix job

* ci: put secret on env

* fix: fix nagivation when creating notes and tasks from the home page

* test: add sidebar test cases

* chore: test files cleanup
This commit is contained in:
2025-03-13 13:56:57 -03:00
committed by GitHub
parent 76cbf4ae78
commit a7f25cbdfd
22 changed files with 232 additions and 160 deletions
+33 -82
View File
@@ -103,62 +103,6 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
client-dokploy:
name: Deploy Client Changes to Stage
runs-on: ubuntu-latest
needs: client-docker-build
if: github.event.pull_request.user.login == 'rmcampos' && github.event_name == 'pull_request'
env:
STAGE_DOMAIN: ${{ vars.STAGE_DOMAIN }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
APP_ID: ${{ secrets.STAGE_WEB_CLIENT_ID }}
steps:
- name: Pre-deployment check
run: |
if ! curl -s -f "${STAGE_DOMAIN}/"; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy"
fi
- name: Trigger Dokploy Deployment
uses: nick-fields/retry@v3.0.2
with:
timeout_minutes: 2
max_attempts: 3
command: |
response=$(curl -X POST \
"${STAGE_DOMAIN}/api/application.deploy" \
--max-time 30 \
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"json\":{\"applicationId\":\"${APP_ID}\"}}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "Status code: $status_code"
echo "Response body: $body"
if [ "$status_code" -ge 400 ]; then
echo "Deployment failed with status code $status_code"
exit 1
fi
- name: Verify deployment
run: |
# Wait for deployment to complete
sleep 30
if ! curl -s -f "${STAGE_DOMAIN}/"; then
echo "Stage environment is not healthy"
exit 1
fi
echo "Health check passed."
java-code-checks:
needs: changes
if: ${{ needs.changes.outputs.server == 'true' }}
@@ -243,65 +187,72 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
server-dokploy:
name: Deploy API Changes to Stage
stage-deployments:
name: Deploy Changes to Stage
runs-on: ubuntu-latest
needs: client-docker-build
needs: [java-docker-build, client-docker-build]
if: github.event.pull_request.user.login == 'rmcampos' && github.event_name == 'pull_request'
env:
STAGE_DOMAIN: ${{ vars.STAGE_DOMAIN }}
STAGE_API_DOMAIN: ${{ vars.STAGE_API_DOMAIN }}
DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }}
API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
APP_ID: ${{ secrets.STAGE_API_CLIENT_ID }}
SERVER_APPID: ${{ secrets.STAGE_API_CLIENT_ID }}
CLIENT_APPID: ${{ secrets.STAGE_WEB_CLIENT_ID }}
strategy:
matrix:
name: [server, client]
include:
- name: server
health_check_url: "${{ vars.API_STAGE_URL }}/actuator/health"
app_id: ${SERVER_APPID}
- name: client
health_check_url: "${{ vars.CLIENT_STAGE_URL }}/"
app_id: ${CLIENT_APPID}
steps:
- name: Pre-deployment check
run: |
response=$(curl -s "${STAGE_API_DOMAIN}/actuator/health")
status=$(echo "$response" | jq -r '.status')
if [ "$status" != "UP" ]; then
echo "Stage environment is not healthy. Status: $status"
if ! curl -s -f "${{ matrix.health_check_url }}"; then
echo "Stage environment is not healthy"
else
echo "Stage environment is healthy!"
echo "Stage environment is healthy"
fi
- name: Trigger Dokploy Deployment
- name: Trigger Deployment
uses: nick-fields/retry@v3.0.2
with:
timeout_minutes: 2
max_attempts: 3
command: |
response=$(curl -X POST \
"${STAGE_DOMAIN}/api/application.deploy" \
"${DEPLOY_DOMAIN}/api/application.deploy" \
--max-time 30 \
-H "accept: application/json" \
-H "x-api-key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"json\":{\"applicationId\":\"${APP_ID}\"}}" \
-d "{\"applicationId\":\"${{ matrix.app_id }}\"}" \
-w "\n%{http_code}" \
-s)
status_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo "Status code: $status_code"
echo "Response body: $body"
if [ "$status_code" -ge 400 ]; then
body=$(echo "$response" | sed '$d')
echo "Deployment failed with status code $status_code"
echo "Response body: $body"
exit 1
else
echo "Deployment succeeded!"
fi
- name: Verify deployment
run: |
# Wait for deployment to complete
sleep 30
response=$(curl -s "${STAGE_API_DOMAIN}/actuator/health")
status=$(echo "$response" | jq -r '.status')
if [ "$status" != "UP" ]; then
echo "Stage environment is not healthy. Status: $status"
echo "Full response: $response"
if ! curl -s -f "${{ matrix.health_check_url }}"; then
echo "Stage environment is not healthy"
exit 1
fi
echo "Health check passed. Status: $status"
else
echo "Stage environment is healthy"
fi
+1 -1
View File
@@ -26,7 +26,7 @@ function App(): React.ReactNode {
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
/**
* Routes for users who are not signed in.
* Routes for the users who are not signed in.
* @type {RouteObject[]}
*/
const notSignedRouter: RouteObject[] = [
+10 -2
View File
@@ -1,14 +1,22 @@
import React from 'react';
import { test } from 'vitest';
import { test, vi } from 'vitest';
import App from '../App';
import { render } from '@testing-library/react';
import AuthContext from '../context/AuthContext';
import authContextMock from './__mocks__/authContextMock';
import SidebarContext from '../context/SidebarContext';
const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
};
test('Renders the app', () => {
render(
<AuthContext.Provider value={authContextMock}>
<App />
<SidebarContext.Provider value={sidebarContextMock}>
<App />
</SidebarContext.Provider>
</AuthContext.Provider>
);
});
@@ -1,6 +1,6 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { MemoryRouter } from 'react-router';
import ContentHeader from '../../components/ContentHeader';
@@ -1,7 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import Header from '../../components/Header';
import '../../i18n';
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router';
import { I18nextProvider } from 'react-i18next';
import Sidebar from '../../components/Sidebar';
import AuthContext from '../../context/AuthContext';
import SidebarContext from '../../context/SidebarContext';
import i18n from '../../i18n';
const authContextMock = {
@@ -25,13 +26,20 @@ const authContextMock = {
updateUser: vi.fn(),
};
const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
};
describe('Sidebar Component', () => {
const renderSidebar = () => {
return render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<Sidebar />
<SidebarContext.Provider value={sidebarContextMock}>
<Sidebar />
</SidebarContext.Provider>
</I18nextProvider>
</AuthContext.Provider>
</MemoryRouter>
@@ -6,7 +6,6 @@ import userEvent from '@testing-library/user-event';
import AuthProvider from '../../context/AuthProvider';
import AuthContext, { AuthContextData } from '../../context/AuthContext';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import { API_TOKEN, USER_DATA } from '../../app-constants/app-constants';
// Mock the API service methods.
@@ -0,0 +1,66 @@
// AuthProvider.test.tsx
import React, { useContext } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SidebarProvider from '../../context/SidebarProvider';
import SidebarContext, { SidebarContextData } from '../../context/SidebarContext';
// Create a helper component to consume AuthContext for testing.
const ConsumerComponent: React.FC = () => {
const {
currentPage,
setNewPage
} = useContext<SidebarContextData>(SidebarContext);
return (
<div>
<div data-testid="page">{currentPage}</div>
<button
data-testid="setPage"
onClick={() => {
setNewPage('/another');
}}
>
Change page
</button>
</div>
);
};
describe('SidebarProvider', () => {
// Reset DOM and mocks for each test.
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it('should render the default context values', () => {
const { getByTestId } = render(
<SidebarProvider>
<ConsumerComponent />
</SidebarProvider>
);
expect(getByTestId('page').textContent).toBe('/home');
});
it('should set a new page after click', async () => {
const { getByTestId } = render(
<SidebarProvider>
<ConsumerComponent />
</SidebarProvider>
);
await act(async () => {
userEvent.click(getByTestId('setPage'));
});
await waitFor(() =>
expect(getByTestId('page').textContent).toBe('/another')
);
});
});
@@ -11,6 +11,7 @@ const tasks: TaskResponse[] = [
dueDate: '',
dueDateFmt: '',
lastUpdate: 'Moments ago',
tag: 'test',
urls: []
}
];
-1
View File
@@ -1,5 +1,4 @@
import React from 'react';
// import { MemoryRouter } from 'react-router';
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import About from '../../views/About';
+3 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, fireEvent, waitFor, getByTestId } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { I18nextProvider } from 'react-i18next';
import { MemoryRouter } from 'react-router';
@@ -35,7 +35,8 @@ const authContextMock = {
name: 'Ricardo',
email: 'test@example.com',
admin: false,
createdAt: new Date()
createdAt: new Date(),
gravatarImageUrl: 'http://image.com'
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
+28 -17
View File
@@ -1,8 +1,9 @@
import React from 'react';
// import { MemoryRouter } from 'react-router';
import { render } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { SummaryResponse } from '../../types/SummaryResponse';
import api from '../../api-service/api';
import Home from '../../views/Home';
import '../../i18n';
import AuthContext from '../../context/AuthContext';
@@ -21,30 +22,40 @@ const authContextMock = {
name: 'Ricardo',
email: 'ricardo@campos.com',
admin: false,
createdAt: new Date()
createdAt: new Date(),
gravatarImageUrl: 'http://image.com'
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false,
updateUser: vi.fn(),
updateUser: vi.fn()
};
describe('Renders the home view', () => {
it('should render text based on new contentHeader component', () => {
const { getByText } = render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<Home />
</AuthContext.Provider>
</MemoryRouter>
);
it('should render text based on new contentHeader component', async () => {
const mockData: SummaryResponse = {
pendingTaskCount: 354,
doneTaskCount: 555,
notesCount: 2222
};
const mockedGetJSON = vi.spyOn(api, 'getJSON').mockResolvedValue(mockData);
expect(getByText('Hello,')).toBeDefined();
expect(getByText('Ricardo')).toBeDefined();
expect(getByText('Welcome to TaskNote! Get ready to complete your pending tasks')).toBeDefined();
expect(getByText('Start Your Day, Be')).toBeDefined();
expect(getByText('Productive')).toBeDefined();
await act(async () => {
render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<Home />
</AuthContext.Provider>
</MemoryRouter>
);
});
expect(screen.getByText('Hello,')).toBeDefined();
expect(screen.getByText('Ricardo')).toBeDefined();
expect(screen.getByText('Welcome to TaskNote! Get ready to complete your pending tasks')).toBeDefined();
expect(screen.getByText('Start Your Day, Be')).toBeDefined();
expect(screen.getByText('Productive')).toBeDefined();
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, fireEvent, waitFor, getByText, getByTestId } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { I18nextProvider } from 'react-i18next';
@@ -99,8 +99,8 @@ describe('NoteAdd Component', () => {
it('should render text based on new contentHeader component', () => {
const { getByText } = renderNoteAdd();
expect(getByText('All')).toBeDefined();
expect(getByText('Notes')).toBeDefined();
expect(getByText('Add')).toBeDefined();
expect(getByText('Note')).toBeDefined();
expect(getByText('Save your notes in plain text or Markdown format')).toBeDefined();
expect(getByText('Create, Filter, and Easily Find')).toBeDefined();
expect(getByText('Them')).toBeDefined();
+3 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { render, fireEvent, waitFor, getByText } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { I18nextProvider } from 'react-i18next';
@@ -100,8 +100,8 @@ describe('TaskAdd Component', () => {
it('should render text based on new contentHeader component', () => {
const { getByText } = renderTaskAdd();
expect(getByText('All')).toBeDefined();
expect(getByText('Tasks')).toBeDefined();
expect(getByText('Add')).toBeDefined();
expect(getByText('Task')).toBeDefined();
expect(getByText('Be on top of your TODO list')).toBeDefined();
expect(getByText('Create, Filter, and Easily Find')).toBeDefined();
expect(getByText('Them')).toBeDefined();
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useContext } from 'react';
import { Col, Row } from 'react-bootstrap';
import { PlusCircleFill } from 'react-bootstrap-icons';
import { NavLink } from 'react-router';
import SidebarContext from '../../context/SidebarContext';
type Props = {
h1TextRegular: string;
@@ -24,6 +25,8 @@ type Props = {
* @returns {React.ReactNode} The rendered ContentHeader component.
*/
const ContentHeader: React.FC<Props> = (props: Props): React.ReactNode => {
const { setNewPage } = useContext(SidebarContext);
return (
<>
<h1 className="poppins-regular home-hello main-margin">
@@ -47,22 +50,22 @@ const ContentHeader: React.FC<Props> = (props: Props): React.ReactNode => {
</Col>
{props.isHomeComponent && (
<Col xs={4} className="text-end">
<NavLink to="/tasks/new">
<NavLink to="/tasks/new" onClick={() => setNewPage('/tasks/new')}>
<button
type="button"
className="home-new-item w-45 mb-2"
>
<PlusCircleFill size={25} />
Add note
Add task
</button>
</NavLink>
<NavLink to="/notes/new">
<NavLink to="/notes/new" onClick={() => setNewPage('/notes/new')}>
<button
type="button"
className="home-new-item w-45"
>
<PlusCircleFill size={25} />
Add task
Add note
</button>
</NavLink>
</Col>
+19 -34
View File
@@ -1,8 +1,9 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { Nav } from 'react-bootstrap';
import { NavLink } from 'react-router';
import { useTranslation } from 'react-i18next';
import AuthContext from '../../context/AuthContext';
import SidebarContext from '../../context/SidebarContext';
import NavButton from '../NavButton';
import SidebarIcon from '../SidebarIcon';
import { env } from '../../env';
@@ -15,28 +16,12 @@ import './style.css';
*/
function Sidebar(): React.ReactNode {
const { signOut, user } = useContext(AuthContext);
const { currentPage, setNewPage } = useContext(SidebarContext);
const { t } = useTranslation();
const build = `Build: ${env.VITE_BUILD}`;
const [current, setCurrent] = useState<string>('/home');
// Note: when selected, change class to plus-jakarta-sans-thin and add background
/**
* Handles the sign-out action.
*/
const goOut = (): void => {
signOut();
};
/**
* Handles the navigation link click event.
*
* @param {string} menu - The menu path.
*/
const navLinkClicked = (menu: string): void => {
setCurrent(menu);
};
useEffect(() => {}, [user]);
return (
@@ -51,29 +36,29 @@ function Sidebar(): React.ReactNode {
<div className="sidebar-menu-header plus-jakarta-sans-regular">Main Menu</div>
<Nav className="flex-column p-3 plus-jakarta-sans-thin">
<NavLink to="/home" className="mb-2" onClick={() => navLinkClicked('/home')}>
<div className={`sidebar-nav ${current === '/home' ? 'selected' : ''}`}>
<NavLink to="/home" className="mb-2" onClick={() => setNewPage('/home')}>
<div className={`sidebar-nav ${currentPage === '/home' ? 'selected' : ''}`}>
<SidebarIcon
iconName="dashboard"
selected={current === '/home'}
selected={currentPage === '/home'}
/>
Dashboard
</div>
</NavLink>
<NavLink to="/tasks" className="mb-2" onClick={() => navLinkClicked('/tasks')}>
<div className={`sidebar-nav ${current === '/tasks' ? 'selected' : ''}`}>
<NavLink to="/tasks" className="mb-2" onClick={() => setNewPage('/tasks')}>
<div className={`sidebar-nav ${currentPage.includes('/tasks') ? 'selected' : ''}`}>
<SidebarIcon
iconName="tasks"
selected={current === '/tasks'}
selected={currentPage.includes('/tasks')}
/>
{t('home_nav_tasks')}
</div>
</NavLink>
<NavLink to="/notes" className="mb-2" onClick={() => navLinkClicked('/notes')}>
<div className={`sidebar-nav ${current === '/notes' ? 'selected' : ''}`}>
<NavLink to="/notes" className="mb-2" onClick={() => setNewPage('/notes')}>
<div className={`sidebar-nav ${currentPage.includes('/notes') ? 'selected' : ''}`}>
<SidebarIcon
iconName="notes"
selected={current === '/notes'}
selected={currentPage.includes('/notes')}
/>
{t('home_nav_notes')}
</div>
@@ -83,25 +68,25 @@ function Sidebar(): React.ReactNode {
<div className="sidebar-menu-header plus-jakarta-sans-regular">Preferences</div>
<Nav className="flex-column p-3 plus-jakarta-sans-thin">
<NavLink to="/account" className="mb-2" onClick={() => navLinkClicked('/account')}>
<div className={`sidebar-nav ${current === '/account' ? 'selected' : ''}`}>
<NavLink to="/account" className="mb-2" onClick={() => setNewPage('/account')}>
<div className={`sidebar-nav ${currentPage === '/account' ? 'selected' : ''}`}>
<SidebarIcon
iconName="account"
selected={current === '/account'}
selected={currentPage === '/account'}
/>
{t('footer_my_account')}
</div>
</NavLink>
<NavLink to="/about" className="mb-2" onClick={() => navLinkClicked('/about')}>
<div className={`sidebar-nav ${current === '/about' ? 'selected' : ''}`}>
<NavLink to="/about" className="mb-2" onClick={() => setNewPage('/about')}>
<div className={`sidebar-nav ${currentPage === '/about' ? 'selected' : ''}`}>
<SidebarIcon
iconName="about"
selected={current === '/about'}
selected={currentPage === '/about'}
/>
{t('home_nav_about')}
</div>
</NavLink>
<NavButton className="mb-2" onClick={() => goOut()}>
<NavButton className="mb-2" onClick={() => signOut()}>
<div className="sidebar-nav">
<SidebarIcon
iconName="logout"
+10
View File
@@ -0,0 +1,10 @@
import { createContext } from 'react';
export interface SidebarContextData {
currentPage: string;
setNewPage: (page: string) => void;
}
const SidebarContext = createContext<SidebarContextData>({} as SidebarContextData);
export default SidebarContext;
+27
View File
@@ -0,0 +1,27 @@
import React, { useMemo, useState } from 'react';
import SidebarContext, { SidebarContextData } from './SidebarContext';
interface Props {
children: React.ReactNode;
}
const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Props) => {
const [currentPage, setCurrentPage] = useState<string>('/home');
const setNewPage = (page: string): void => {
setCurrentPage(page);
};
const contextValue: SidebarContextData = useMemo(() => ({
currentPage,
setNewPage
}), [currentPage, setNewPage]);
return (
<SidebarContext.Provider value={contextValue}>
{ children }
</SidebarContext.Provider>
);
};
export default SidebarProvider;
+4 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import AuthProvider from './context/AuthProvider';
import SidebarProvider from './context/SidebarProvider';
import './i18n';
window.global ||= window;
@@ -13,7 +14,9 @@ const root = createRoot(
root.render(
<React.StrictMode>
<AuthProvider>
<App />
<SidebarProvider>
<App />
</SidebarProvider>
</AuthProvider>
</React.StrictMode>
);
+2 -2
View File
@@ -188,8 +188,8 @@ function NoteAdd(): React.ReactNode {
return (
<Container>
<ContentHeader
h1TextRegular="All"
h1TextBold="Notes"
h1TextRegular="Add"
h1TextBold="Note"
subtitle="Save your notes in plain text or Markdown format"
h2BlackText="Create, Filter, and Easily Find"
h2GreenText="Them"
+2 -2
View File
@@ -192,8 +192,8 @@ function TaskAdd(): React.ReactNode {
return (
<Container>
<ContentHeader
h1TextRegular="All"
h1TextBold="Tasks"
h1TextRegular="Add"
h1TextBold="Task"
subtitle="Be on top of your TODO list"
h2BlackText="Create, Filter, and Easily Find"
h2GreenText="Them"
@@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** This class contains resources for handling authentication. */
/** This class contains the resources for handling authentication. */
@RestController
@RequestMapping("/auth")
@Tag(