feat(migrations): add README for node-pg-migrate and enhance frontend configuration
- Created a README file for database migrations using node-pg-migrate, detailing commands, file format, best practices, and an example migration. - Added a Vite configuration file for the frontend, including plugin setup, alias resolution, build optimizations, and responsive design adjustments for task list components. - Updated i18n configuration to preload the 'home' namespace for improved localization. - Enhanced task list styling with responsive design features for better mobile usability.
This commit is contained in:
72
worklenz-backend/database/pg-migrations/README.md
Normal file
72
worklenz-backend/database/pg-migrations/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Node-pg-migrate Migrations
|
||||||
|
|
||||||
|
This directory contains database migrations managed by node-pg-migrate.
|
||||||
|
|
||||||
|
## Migration Commands
|
||||||
|
|
||||||
|
- `npm run migrate:create -- migration-name` - Create a new migration file
|
||||||
|
- `npm run migrate:up` - Run all pending migrations
|
||||||
|
- `npm run migrate:down` - Rollback the last migration
|
||||||
|
- `npm run migrate:redo` - Rollback and re-run the last migration
|
||||||
|
|
||||||
|
## Migration File Format
|
||||||
|
|
||||||
|
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
|
||||||
|
|
||||||
|
Each migration file exports two functions:
|
||||||
|
- `exports.up` - Contains the forward migration logic
|
||||||
|
- `exports.down` - Contains the rollback logic
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
|
||||||
|
2. **Test migrations locally** before deploying to production
|
||||||
|
3. **Include rollback logic** in the `down` function for all changes
|
||||||
|
4. **Use descriptive names** for migration files
|
||||||
|
5. **Keep migrations focused** - one logical change per migration
|
||||||
|
|
||||||
|
## Example Migration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
exports.up = pgm => {
|
||||||
|
// Create table with IF NOT EXISTS
|
||||||
|
pgm.createTable('users', {
|
||||||
|
id: 'id',
|
||||||
|
name: { type: 'varchar(100)', notNull: true },
|
||||||
|
created_at: {
|
||||||
|
type: 'timestamp',
|
||||||
|
notNull: true,
|
||||||
|
default: pgm.func('current_timestamp')
|
||||||
|
}
|
||||||
|
}, { ifNotExists: true });
|
||||||
|
|
||||||
|
// Add index with IF NOT EXISTS
|
||||||
|
pgm.createIndex('users', 'name', {
|
||||||
|
name: 'idx_users_name',
|
||||||
|
ifNotExists: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = pgm => {
|
||||||
|
// Drop in reverse order
|
||||||
|
pgm.dropIndex('users', 'name', {
|
||||||
|
name: 'idx_users_name',
|
||||||
|
ifExists: true
|
||||||
|
});
|
||||||
|
|
||||||
|
pgm.dropTable('users', { ifExists: true });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration History
|
||||||
|
|
||||||
|
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
|
||||||
|
|
||||||
|
## Converting from SQL Migrations
|
||||||
|
|
||||||
|
When converting SQL migrations to node-pg-migrate format:
|
||||||
|
|
||||||
|
1. Wrap SQL statements in `pgm.sql()` calls
|
||||||
|
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
|
||||||
|
3. Always include `IF EXISTS/IF NOT EXISTS` checks
|
||||||
|
4. Ensure proper rollback logic in the `down` function
|
||||||
@@ -10,6 +10,7 @@ i18n
|
|||||||
.init({
|
.init({
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
ns: ['common', 'home'], // Preload home namespace
|
||||||
|
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
|
|||||||
@@ -6,3 +6,81 @@
|
|||||||
.ant-table-row:hover .row-action-button {
|
.ant-table-row:hover .row-action-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive styles for task list */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-list-card .ant-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-card .ant-card-head-title {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-card .ant-card-extra {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-mobile-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-mobile-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-mobile-select {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-mobile-segmented {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.task-list-card .ant-table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-card .ant-table-thead > tr > th {
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-card .ant-table-tbody > tr > td {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action-button {
|
||||||
|
opacity: 1; /* Always show on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide project column on very small screens */
|
||||||
|
.task-list-card .ant-table-thead > tr > th:nth-child(2),
|
||||||
|
.task-list-card .ant-table-tbody > tr > td:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table responsive container */
|
||||||
|
.task-list-card .ant-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-list-card .ant-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-card .ant-table {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|
||||||
import ListView from './list-view';
|
import ListView from './list-view';
|
||||||
import CalendarView from './calendar-view';
|
import CalendarView from './calendar-view';
|
||||||
@@ -61,21 +62,22 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
refetchOnFocus: false,
|
refetchOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation('home');
|
const { t, ready } = useTranslation('home');
|
||||||
const { model } = useAppSelector(state => state.homePageReducer);
|
const { model } = useAppSelector(state => state.homePageReducer);
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 });
|
||||||
|
|
||||||
const taskModes = useMemo(
|
const taskModes = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: 0,
|
value: 0,
|
||||||
label: t('home:tasks.assignedToMe'),
|
label: ready ? t('tasks.assignedToMe') : 'Assigned to me',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 1,
|
value: 1,
|
||||||
label: t('home:tasks.assignedByMe'),
|
label: ready ? t('tasks.assignedByMe') : 'Assigned by me',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t]
|
[t, ready]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSegmentChange = (value: 'List' | 'Calendar') => {
|
const handleSegmentChange = (value: 'List' | 'Calendar') => {
|
||||||
@@ -123,7 +125,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
<span>{t('tasks.name')}</span>
|
<span>{t('tasks.name')}</span>
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
width: '40%',
|
width: isMobile ? '50%' : '40%',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Tooltip title={record.name}>
|
<Tooltip title={record.name}>
|
||||||
@@ -155,7 +157,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
{
|
{
|
||||||
key: 'project',
|
key: 'project',
|
||||||
title: t('tasks.project'),
|
title: t('tasks.project'),
|
||||||
width: '25%',
|
width: isMobile ? '30%' : '25%',
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
return (
|
return (
|
||||||
<Tooltip title={record.project_name}>
|
<Tooltip title={record.project_name}>
|
||||||
@@ -185,7 +187,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
render: (_, record) => <HomeTasksDatePicker record={record} />,
|
render: (_, record) => <HomeTasksDatePicker record={record} />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, data?.body?.total, currentPage, pageSize, handlePageChange]
|
[t, data?.body?.total, currentPage, pageSize, handlePageChange, isMobile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTaskModeChange = (value: number) => {
|
const handleTaskModeChange = (value: number) => {
|
||||||
@@ -210,23 +212,27 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
);
|
);
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
className="task-list-card"
|
||||||
title={
|
title={
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center" className="task-list-mobile-header">
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
{t('tasks.tasks')}
|
{t('tasks.tasks')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Select
|
<Select
|
||||||
defaultValue={taskModes[0].label}
|
value={homeTasksConfig.tasks_group_by || 0}
|
||||||
options={taskModes}
|
options={taskModes}
|
||||||
onChange={value => handleTaskModeChange(+value)}
|
onChange={value => handleTaskModeChange(+value)}
|
||||||
fieldNames={{ label: 'label', value: 'value' }}
|
fieldNames={{ label: 'label', value: 'value' }}
|
||||||
|
className="task-list-mobile-select"
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Flex gap={8} align="center">
|
<Flex gap={8} align="center" className="task-list-mobile-controls">
|
||||||
<Tooltip title={t('tasks.refresh')} trigger={'hover'}>
|
<Tooltip title={t('tasks.refresh')} trigger={'hover'}>
|
||||||
<Button
|
<Button
|
||||||
shape="circle"
|
shape="circle"
|
||||||
@@ -241,6 +247,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
]}
|
]}
|
||||||
defaultValue="List"
|
defaultValue="List"
|
||||||
onChange={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
|
className="task-list-mobile-segmented"
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
@@ -283,6 +290,7 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
rowClassName={() => 'custom-row-height'}
|
rowClassName={() => 'custom-row-height'}
|
||||||
loading={homeTasksFetching && skipAutoRefetch}
|
loading={homeTasksFetching && skipAutoRefetch}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user