improved project architecture (BE and FE)
This commit is contained in:
parent
25e6a1f5d2
commit
b66cf94fb4
@ -119,7 +119,7 @@ Three-tier environment model with separate content per environment:
|
|||||||
```
|
```
|
||||||
Dev Environment Stage Environment Production Environment
|
Dev Environment Stage Environment Production Environment
|
||||||
│ │ │
|
│ │ │
|
||||||
/constructor?projectId= /p/[slug]/stage /p/[slug]
|
/constructor?projectId= /p/[projectSlug]/stage /p/[projectSlug]
|
||||||
(editing mode) (preview) (public access)
|
(editing mode) (preview) (public access)
|
||||||
│ │ │
|
│ │ │
|
||||||
└── Save to Stage ──────►└── Publish ─────────────►│
|
└── Save to Stage ──────►└── Publish ─────────────►│
|
||||||
@ -225,7 +225,7 @@ EMAIL_PASS=...
|
|||||||
### Frontend (`frontend/.env.local`)
|
### Frontend (`frontend/.env.local`)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
NEXT_PUBLIC_BACK_API=http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|||||||
@ -111,7 +111,9 @@ backend/src/
|
|||||||
├── middlewares/
|
├── middlewares/
|
||||||
│ ├── check-permissions.js # RBAC permission checking
|
│ ├── check-permissions.js # RBAC permission checking
|
||||||
│ ├── runtime-context.js # Environment detection from headers
|
│ ├── runtime-context.js # Environment detection from headers
|
||||||
│ └── runtime-public.js # Public runtime access (no auth)
|
│ ├── runtime-public.js # Public runtime access (no auth)
|
||||||
|
│ ├── upload.js # File upload handling (multer)
|
||||||
|
│ └── rateLimiter.js # Rate limiting for API endpoints
|
||||||
│
|
│
|
||||||
├── factories/
|
├── factories/
|
||||||
│ ├── router.factory.js # Generate CRUD routes
|
│ ├── router.factory.js # Generate CRUD routes
|
||||||
@ -302,8 +304,13 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
|
|||||||
|
|
||||||
| Role | Description |
|
| Role | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| Administrator | Full access to all features |
|
| Administrator | Full access to all features (user/role/permission management) |
|
||||||
| Analytics Viewer | Read-only access for analytics |
|
| Platform Owner | Full project access, user management |
|
||||||
|
| Account Manager | Project and asset management |
|
||||||
|
| Tour Designer | Create and edit tours, assets, pages |
|
||||||
|
| Content Reviewer | Review and update content (read/update access) |
|
||||||
|
| Analytics Viewer | Read-only access for viewing data |
|
||||||
|
| Public | Minimal access for public users |
|
||||||
|
|
||||||
## Environment Detection
|
## Environment Detection
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Access_logsDBApi extends GenericDBApi {
|
class Access_logsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -53,6 +49,30 @@ class Access_logsDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{ model: db.projects, as: 'project', required: false },
|
||||||
|
{ model: db.users, as: 'user', required: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filterKey: 'user',
|
||||||
|
model: db.users,
|
||||||
|
as: 'user',
|
||||||
|
searchField: 'firstName',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -63,136 +83,6 @@ class Access_logsDBApi extends GenericDBApi {
|
|||||||
accessed_at: data.accessed_at || null,
|
accessed_at: data.accessed_at || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'user',
|
|
||||||
where: filter.user
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Access_logsDBApi;
|
module.exports = Access_logsDBApi;
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Asset_variantsDBApi extends GenericDBApi {
|
class Asset_variantsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -50,6 +46,27 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'asset' }];
|
return [{ association: 'asset' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
model: db.assets,
|
||||||
|
as: 'asset',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'asset',
|
||||||
|
model: db.assets,
|
||||||
|
as: 'asset',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -60,112 +77,6 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
size_mb: data.size_mb || null,
|
size_mb: data.size_mb || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.assets,
|
|
||||||
as: 'asset',
|
|
||||||
where: filter.asset
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.asset
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.asset
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Asset_variantsDBApi;
|
module.exports = Asset_variantsDBApi;
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class AssetsDBApi extends GenericDBApi {
|
class AssetsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -55,6 +51,10 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [{ model: db.projects, as: 'project', required: false }];
|
||||||
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static get RELATION_FILTERS() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -83,112 +83,6 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
is_public: data.is_public || false,
|
is_public: data.is_public || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AssetsDBApi;
|
module.exports = AssetsDBApi;
|
||||||
|
|||||||
@ -50,8 +50,83 @@ class GenericDBApi {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields that should be automatically JSON-stringified
|
||||||
|
* Override in subclass to specify fields.
|
||||||
|
* Example: return ['settings_json', 'metadata_json'];
|
||||||
|
*/
|
||||||
|
static get JSON_FIELDS() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom field transformers for data mapping.
|
||||||
|
* Override in subclass to add custom transformations.
|
||||||
|
* Example:
|
||||||
|
* return {
|
||||||
|
* email: (value) => value?.toLowerCase().trim(),
|
||||||
|
* slug: (value) => value?.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
static get FIELD_TRANSFORMERS() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field mapping configuration for declarative field handling.
|
||||||
|
* Override in subclass to specify how fields should be mapped.
|
||||||
|
* Example:
|
||||||
|
* return {
|
||||||
|
* name: { default: null },
|
||||||
|
* sort_order: { default: 0 },
|
||||||
|
* is_active: { default: true },
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
static get FIELD_DEFAULTS() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform input data for database operations.
|
||||||
|
* Template Method Pattern: Uses JSON_FIELDS, FIELD_TRANSFORMERS, and FIELD_DEFAULTS
|
||||||
|
* to declaratively transform data, reducing boilerplate in subclasses.
|
||||||
|
*
|
||||||
|
* Override this method for complex custom transformations that can't be
|
||||||
|
* expressed declaratively.
|
||||||
|
*
|
||||||
|
* @param {Object} data - Input data to transform
|
||||||
|
* @returns {Object} - Transformed data ready for database
|
||||||
|
*/
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return data;
|
if (!data) return data;
|
||||||
|
const mapped = { ...data };
|
||||||
|
|
||||||
|
// Apply field defaults
|
||||||
|
for (const [field, config] of Object.entries(this.FIELD_DEFAULTS)) {
|
||||||
|
if (mapped[field] === undefined) {
|
||||||
|
mapped[field] = config.default;
|
||||||
|
} else if (mapped[field] === null && config.nullDefault !== undefined) {
|
||||||
|
mapped[field] = config.nullDefault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-stringify JSON fields
|
||||||
|
for (const field of this.JSON_FIELDS) {
|
||||||
|
if (mapped[field] !== undefined && mapped[field] !== null) {
|
||||||
|
if (typeof mapped[field] !== 'string') {
|
||||||
|
mapped[field] = JSON.stringify(mapped[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom transformers
|
||||||
|
for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) {
|
||||||
|
if (mapped[field] !== undefined) {
|
||||||
|
mapped[field] = transformer(mapped[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(data, options = {}) {
|
static async create(data, options = {}) {
|
||||||
|
|||||||
@ -37,19 +37,29 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
return 'name';
|
return 'name';
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
// Declarative field configuration using base class patterns
|
||||||
|
static get JSON_FIELDS() {
|
||||||
|
return ['default_settings_json'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get FIELD_DEFAULTS() {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
element_type: { default: null },
|
||||||
element_type: data.element_type ?? null,
|
name: { default: null },
|
||||||
name: data.name ?? null,
|
sort_order: { default: 0 },
|
||||||
sort_order: data.sort_order ?? 0,
|
};
|
||||||
default_settings_json:
|
}
|
||||||
data.default_settings_json === null ||
|
|
||||||
data.default_settings_json === undefined
|
static getFieldMapping(data) {
|
||||||
? null
|
// Apply base class transformations (JSON fields, defaults, transformers)
|
||||||
: typeof data.default_settings_json === 'string'
|
const mapped = super.getFieldMapping(data);
|
||||||
? data.default_settings_json
|
|
||||||
: JSON.stringify(data.default_settings_json),
|
return {
|
||||||
|
id: mapped.id || undefined,
|
||||||
|
element_type: mapped.element_type,
|
||||||
|
name: mapped.name,
|
||||||
|
sort_order: mapped.sort_order,
|
||||||
|
default_settings_json: mapped.default_settings_json,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const services = require('../../services/file');
|
const services = require('../../services/file/');
|
||||||
|
|
||||||
module.exports = class FileDBApi {
|
module.exports = class FileDBApi {
|
||||||
static async replaceRelationFiles(relation, rawFiles, options) {
|
static async replaceRelationFiles(relation, rawFiles, options) {
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Presigned_url_requestsDBApi extends GenericDBApi {
|
class Presigned_url_requestsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -53,6 +49,30 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{ model: db.projects, as: 'project', required: false },
|
||||||
|
{ model: db.users, as: 'user', required: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filterKey: 'user',
|
||||||
|
model: db.users,
|
||||||
|
as: 'user',
|
||||||
|
searchField: 'firstName',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -65,136 +85,6 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
|||||||
status: data.status || null,
|
status: data.status || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'user',
|
|
||||||
where: filter.user
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Presigned_url_requestsDBApi;
|
module.exports = Presigned_url_requestsDBApi;
|
||||||
|
|||||||
@ -61,21 +61,39 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
|||||||
return 'name';
|
return 'name';
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
// Declarative field configuration using base class patterns
|
||||||
|
static get JSON_FIELDS() {
|
||||||
|
return ['settings_json'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get FIELD_DEFAULTS() {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
element_type: { default: null },
|
||||||
element_type: data.element_type ?? null,
|
name: { default: null },
|
||||||
name: data.name ?? null,
|
sort_order: { default: 0 },
|
||||||
sort_order: data.sort_order ?? 0,
|
source_element_id: { default: null },
|
||||||
settings_json:
|
snapshot_version: { default: 1 },
|
||||||
data.settings_json === null || data.settings_json === undefined
|
};
|
||||||
? null
|
}
|
||||||
: typeof data.settings_json === 'string'
|
|
||||||
? data.settings_json
|
static getFieldMapping(data) {
|
||||||
: JSON.stringify(data.settings_json),
|
// Apply base class transformations (JSON fields, defaults, transformers)
|
||||||
source_element_id: data.source_element_id ?? null,
|
const mapped = super.getFieldMapping(data);
|
||||||
snapshot_version: data.snapshot_version ?? 1,
|
|
||||||
projectId: data.projectId || data.project || undefined,
|
// Custom mapping for projectId field (accepts both projectId and project)
|
||||||
|
if (mapped.project && !mapped.projectId) {
|
||||||
|
mapped.projectId = mapped.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: mapped.id || undefined,
|
||||||
|
element_type: mapped.element_type,
|
||||||
|
name: mapped.name,
|
||||||
|
sort_order: mapped.sort_order,
|
||||||
|
settings_json: mapped.settings_json,
|
||||||
|
source_element_id: mapped.source_element_id,
|
||||||
|
snapshot_version: mapped.snapshot_version,
|
||||||
|
projectId: mapped.projectId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Project_membershipsDBApi extends GenericDBApi {
|
class Project_membershipsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -52,6 +48,30 @@ class Project_membershipsDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{ model: db.projects, as: 'project', required: false },
|
||||||
|
{ model: db.users, as: 'user', required: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filterKey: 'user',
|
||||||
|
model: db.users,
|
||||||
|
as: 'user',
|
||||||
|
searchField: 'firstName',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -61,130 +81,6 @@ class Project_membershipsDBApi extends GenericDBApi {
|
|||||||
accepted_at: data.accepted_at || null,
|
accepted_at: data.accepted_at || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'user',
|
|
||||||
where: filter.user
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Project_membershipsDBApi;
|
module.exports = Project_membershipsDBApi;
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Publish_eventsDBApi extends GenericDBApi {
|
class Publish_eventsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -60,6 +56,30 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{ model: db.projects, as: 'project', required: false },
|
||||||
|
{ model: db.users, as: 'user', required: false },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filterKey: 'user',
|
||||||
|
model: db.users,
|
||||||
|
as: 'user',
|
||||||
|
searchField: 'firstName',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -76,136 +96,6 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
audios_copied: data.audios_copied || null,
|
audios_copied: data.audios_copied || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: db.users,
|
|
||||||
as: 'user',
|
|
||||||
where: filter.user
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
firstName: {
|
|
||||||
[Op.or]: filter.user
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Publish_eventsDBApi;
|
module.exports = Publish_eventsDBApi;
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class Pwa_cachesDBApi extends GenericDBApi {
|
class Pwa_cachesDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -49,6 +45,21 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'project' }];
|
return [{ association: 'project' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [{ model: db.projects, as: 'project', required: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -60,112 +71,6 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
|||||||
is_active: data.is_active || false,
|
is_active: data.is_active || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.projects,
|
|
||||||
as: 'project',
|
|
||||||
where: filter.project
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: filter.project
|
|
||||||
.split('|')
|
|
||||||
.map((term) => ({ [Op.iLike]: `%${term}%` })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.RANGE_FIELDS) {
|
|
||||||
const rangeKey = `${field}Range`;
|
|
||||||
if (filter[rangeKey]) {
|
|
||||||
const [start, end] = filter[rangeKey];
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where[field] = { ...where[field], [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.ENUM_FIELDS) {
|
|
||||||
if (filter[field] !== undefined) {
|
|
||||||
where[field] = filter[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Pwa_cachesDBApi;
|
module.exports = Pwa_cachesDBApi;
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const Utils = require('../utils');
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
|
||||||
const Op = Sequelize.Op;
|
|
||||||
|
|
||||||
class RolesDBApi extends GenericDBApi {
|
class RolesDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static get MODEL() {
|
||||||
@ -42,6 +38,27 @@ class RolesDBApi extends GenericDBApi {
|
|||||||
return [{ association: 'users_app_role' }, { association: 'permissions' }];
|
return [{ association: 'users_app_role' }, { association: 'permissions' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
model: db.permissions,
|
||||||
|
as: 'permissions',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RELATION_FILTERS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filterKey: 'permissions',
|
||||||
|
model: db.permissions,
|
||||||
|
as: 'permissions_filter',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
@ -49,105 +66,6 @@ class RolesDBApi extends GenericDBApi {
|
|||||||
role_customization: data.role_customization || null,
|
role_customization: data.role_customization || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
|
||||||
filter = filter || {};
|
|
||||||
const limit = filter.limit || 0;
|
|
||||||
const currentPage = +filter.page || 0;
|
|
||||||
const offset = currentPage * limit;
|
|
||||||
|
|
||||||
let where = {};
|
|
||||||
|
|
||||||
let include = [
|
|
||||||
{
|
|
||||||
model: db.permissions,
|
|
||||||
as: 'permissions',
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (filter.id) {
|
|
||||||
where.id = Utils.uuid(filter.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of this.SEARCHABLE_FIELDS) {
|
|
||||||
if (filter[field]) {
|
|
||||||
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.active !== undefined) {
|
|
||||||
where.active = filter.active === true || filter.active === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.permissions) {
|
|
||||||
const searchTerms = filter.permissions.split('|');
|
|
||||||
include = [
|
|
||||||
{
|
|
||||||
model: db.permissions,
|
|
||||||
as: 'permissions_filter',
|
|
||||||
required: searchTerms.length > 0,
|
|
||||||
where:
|
|
||||||
searchTerms.length > 0
|
|
||||||
? {
|
|
||||||
[Op.or]: [
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
[Op.in]: searchTerms.map((term) => Utils.uuid(term)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: {
|
|
||||||
[Op.or]: searchTerms.map((term) => ({
|
|
||||||
[Op.iLike]: `%${term}%`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
...include,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.createdAtRange) {
|
|
||||||
const [start, end] = filter.createdAtRange;
|
|
||||||
if (start !== undefined && start !== null && start !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.gte]: start };
|
|
||||||
}
|
|
||||||
if (end !== undefined && end !== null && end !== '') {
|
|
||||||
where.createdAt = { ...where.createdAt, [Op.lte]: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryOptions = {
|
|
||||||
where,
|
|
||||||
include,
|
|
||||||
distinct: true,
|
|
||||||
order:
|
|
||||||
filter.field && filter.sort
|
|
||||||
? [[filter.field, filter.sort]]
|
|
||||||
: [['createdAt', 'desc']],
|
|
||||||
transaction: options.transaction,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.countOnly) {
|
|
||||||
queryOptions.limit = limit ? Number(limit) : undefined;
|
|
||||||
queryOptions.offset = offset ? Number(offset) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
|
|
||||||
return {
|
|
||||||
rows: options.countOnly ? [] : rows,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error executing query:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = RolesDBApi;
|
module.exports = RolesDBApi;
|
||||||
|
|||||||
@ -10,6 +10,12 @@ const config = require('./config');
|
|||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
const { logger, requestLogger } = require('./utils/logger');
|
const { logger, requestLogger } = require('./utils/logger');
|
||||||
|
const {
|
||||||
|
uploadLimiter,
|
||||||
|
downloadLimiter,
|
||||||
|
searchLimiter,
|
||||||
|
aiLimiter,
|
||||||
|
} = require('./middlewares/rateLimiter');
|
||||||
|
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const fileRoutes = require('./routes/file');
|
const fileRoutes = require('./routes/file');
|
||||||
@ -125,8 +131,13 @@ app.use(requestLogger);
|
|||||||
// Initialize passport JWT auth early (before file routes)
|
// Initialize passport JWT auth early (before file routes)
|
||||||
const jwtAuth = passport.authenticate('jwt', { session: false });
|
const jwtAuth = passport.authenticate('jwt', { session: false });
|
||||||
|
|
||||||
// Mount file upload routes BEFORE body-parser to avoid JSON parsing on binary uploads
|
// Mount file routes BEFORE body-parser to avoid JSON parsing on binary uploads
|
||||||
// These routes handle their own body parsing (JSON for init/finalize, raw streams for chunks)
|
// These routes handle their own body parsing (JSON for init/finalize, raw streams for chunks)
|
||||||
|
// Use downloadLimiter for download/presign (high traffic), uploadLimiter for uploads (strict)
|
||||||
|
app.use('/api/file/download', downloadLimiter);
|
||||||
|
app.use('/api/file/presign', downloadLimiter);
|
||||||
|
app.use('/api/file/upload', uploadLimiter);
|
||||||
|
app.use('/api/file/upload-sessions', uploadLimiter);
|
||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
|
|
||||||
// Body parser for all other routes
|
// Body parser for all other routes
|
||||||
@ -227,10 +238,10 @@ app.use(
|
|||||||
|
|
||||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||||
|
|
||||||
app.use('/api/openai', jwtAuth, openaiRoutes);
|
app.use('/api/openai', jwtAuth, aiLimiter, openaiRoutes);
|
||||||
app.use('/api/ai', jwtAuth, openaiRoutes);
|
app.use('/api/ai', jwtAuth, aiLimiter, openaiRoutes);
|
||||||
|
|
||||||
app.use('/api/search', jwtAuth, searchRoutes);
|
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
|
||||||
app.use('/api/sql', jwtAuth, sqlRoutes);
|
app.use('/api/sql', jwtAuth, sqlRoutes);
|
||||||
|
|
||||||
const publicDir = path.join(__dirname, '../public');
|
const publicDir = path.join(__dirname, '../public');
|
||||||
|
|||||||
268
backend/src/middlewares/rateLimiter.js
Normal file
268
backend/src/middlewares/rateLimiter.js
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Rate Limiter Middleware
|
||||||
|
*
|
||||||
|
* Provides centralized rate limiting for API endpoints using a configurable
|
||||||
|
* memory store with optional Redis support for horizontal scaling.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { authLimiter, apiLimiter, uploadLimiter } = require('./middlewares/rateLimiter');
|
||||||
|
* app.use('/api/auth', authLimiter);
|
||||||
|
* app.use('/api', apiLimiter);
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
|
// In-memory store for rate limiting
|
||||||
|
// For horizontal scaling, replace with Redis store
|
||||||
|
const rateLimitStore = new Map();
|
||||||
|
|
||||||
|
// Cleanup interval for expired entries (every 5 minutes)
|
||||||
|
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired entries
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleaned = 0;
|
||||||
|
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned > 0) {
|
||||||
|
logger.debug({ cleaned }, 'Rate limit store cleanup');
|
||||||
|
}
|
||||||
|
}, CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limiter middleware
|
||||||
|
*
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {string} options.keyPrefix - Prefix for rate limit keys (e.g., 'auth', 'api')
|
||||||
|
* @param {number} options.windowMs - Time window in milliseconds (default: 15 minutes)
|
||||||
|
* @param {number} options.max - Maximum requests per window (default: 100)
|
||||||
|
* @param {string} [options.message] - Custom error message
|
||||||
|
* @param {boolean} [options.skipFailedRequests] - Don't count failed requests (status >= 400)
|
||||||
|
* @param {Function} [options.keyGenerator] - Custom key generator (req) => string
|
||||||
|
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
|
||||||
|
* @returns {Function} Express middleware
|
||||||
|
*/
|
||||||
|
const createRateLimiter = (options = {}) => {
|
||||||
|
const {
|
||||||
|
keyPrefix = 'rate-limit',
|
||||||
|
windowMs = 15 * 60 * 1000, // 15 minutes
|
||||||
|
max = 100,
|
||||||
|
message = 'Too many requests. Please try again later.',
|
||||||
|
skipFailedRequests = false,
|
||||||
|
keyGenerator = null,
|
||||||
|
skip = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return (req, res, next) => {
|
||||||
|
// Allow skipping rate limiting for certain requests
|
||||||
|
if (skip && skip(req)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip in development when accessing from localhost (optional)
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === 'development' &&
|
||||||
|
(req.ip === '127.0.0.1' || req.ip === '::1')
|
||||||
|
) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate rate limit key
|
||||||
|
const clientKey = keyGenerator
|
||||||
|
? keyGenerator(req)
|
||||||
|
: req.ip || req.connection?.remoteAddress || 'unknown';
|
||||||
|
const key = `${keyPrefix}:${clientKey}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Get or create rate limit entry
|
||||||
|
let entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
if (!entry || entry.expiresAt <= now) {
|
||||||
|
// Create new entry
|
||||||
|
entry = {
|
||||||
|
count: 0,
|
||||||
|
expiresAt: now + windowMs,
|
||||||
|
resetTime: new Date(now + windowMs).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard rate limit headers
|
||||||
|
const remaining = Math.max(0, max - entry.count - 1);
|
||||||
|
const retryAfter = Math.ceil((entry.expiresAt - now) / 1000);
|
||||||
|
|
||||||
|
res.setHeader('X-RateLimit-Limit', max);
|
||||||
|
res.setHeader('X-RateLimit-Remaining', remaining);
|
||||||
|
res.setHeader('X-RateLimit-Reset', entry.resetTime);
|
||||||
|
|
||||||
|
// Check if rate limit exceeded
|
||||||
|
if (entry.count >= max) {
|
||||||
|
res.setHeader('Retry-After', retryAfter);
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
ip: clientKey,
|
||||||
|
keyPrefix,
|
||||||
|
count: entry.count,
|
||||||
|
max,
|
||||||
|
retryAfter,
|
||||||
|
},
|
||||||
|
'Rate limit exceeded',
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message,
|
||||||
|
retryAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
entry.count += 1;
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
|
||||||
|
// If skipFailedRequests is enabled, decrement on failed response
|
||||||
|
if (skipFailedRequests) {
|
||||||
|
const originalSend = res.send.bind(res);
|
||||||
|
res.send = function (body) {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
const currentEntry = rateLimitStore.get(key);
|
||||||
|
if (currentEntry && currentEntry.count > 0) {
|
||||||
|
currentEntry.count -= 1;
|
||||||
|
rateLimitStore.set(key, currentEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalSend(body);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limiter that uses both IP and user ID as key
|
||||||
|
* Useful for authenticated endpoints
|
||||||
|
*/
|
||||||
|
const createAuthenticatedRateLimiter = (options = {}) => {
|
||||||
|
return createRateLimiter({
|
||||||
|
...options,
|
||||||
|
keyGenerator: (req) => {
|
||||||
|
const userId = req.currentUser?.id || 'anonymous';
|
||||||
|
const ip = req.ip || 'unknown';
|
||||||
|
return `${ip}:${userId}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-configured limiters for common use cases
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth limiter - Strict limits for authentication endpoints
|
||||||
|
* 5 requests per 15 minutes per IP
|
||||||
|
*/
|
||||||
|
const authLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'auth',
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 10, // Allow 10 attempts per window
|
||||||
|
message: 'Too many authentication attempts. Please try again later.',
|
||||||
|
skipFailedRequests: false, // Count failed attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup limiter - Very strict limits for registration
|
||||||
|
* 5 signups per hour per IP
|
||||||
|
*/
|
||||||
|
const signupLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'signup',
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many signup attempts. Please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password reset limiter - Prevent password reset abuse
|
||||||
|
* 5 requests per hour per IP
|
||||||
|
*/
|
||||||
|
const passwordResetLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'password-reset',
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: 'Too many password reset requests. Please try again later.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General API limiter - Standard limits for API endpoints
|
||||||
|
* 100 requests per minute per IP
|
||||||
|
*/
|
||||||
|
const apiLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'api',
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 100,
|
||||||
|
message: 'Too many requests. Please slow down.',
|
||||||
|
skipFailedRequests: true, // Don't penalize for errors
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload limiter - Stricter limits for file uploads
|
||||||
|
* 10 uploads per minute per IP
|
||||||
|
*/
|
||||||
|
const uploadLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'upload',
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many file uploads. Please wait before uploading more.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download limiter - More permissive limits for file downloads
|
||||||
|
* 200 requests per minute per IP (supports asset preloading)
|
||||||
|
*/
|
||||||
|
const downloadLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'download',
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 200,
|
||||||
|
message: 'Too many download requests. Please slow down.',
|
||||||
|
skipFailedRequests: true, // Don't penalize for errors
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search limiter - Prevent search abuse
|
||||||
|
* 30 searches per minute per IP
|
||||||
|
*/
|
||||||
|
const searchLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'search',
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 30,
|
||||||
|
message: 'Too many search requests. Please slow down.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI/OpenAI limiter - Strict limits for expensive AI operations
|
||||||
|
* 20 requests per minute per IP
|
||||||
|
*/
|
||||||
|
const aiLimiter = createRateLimiter({
|
||||||
|
keyPrefix: 'ai',
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 20,
|
||||||
|
message: 'Too many AI requests. Please wait before making more.',
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createRateLimiter,
|
||||||
|
createAuthenticatedRateLimiter,
|
||||||
|
authLimiter,
|
||||||
|
signupLimiter,
|
||||||
|
passwordResetLimiter,
|
||||||
|
apiLimiter,
|
||||||
|
uploadLimiter,
|
||||||
|
downloadLimiter,
|
||||||
|
searchLimiter,
|
||||||
|
aiLimiter,
|
||||||
|
};
|
||||||
@ -1,124 +0,0 @@
|
|||||||
const { body, param, query, validationResult } = require('express-validator');
|
|
||||||
|
|
||||||
const handleValidationErrors = (req, res, next) => {
|
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Validation failed',
|
|
||||||
details: errors.array().map((err) => ({
|
|
||||||
field: err.path,
|
|
||||||
message: err.msg,
|
|
||||||
value: err.value,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
const validators = {
|
|
||||||
uuid: (field, location = 'param') => {
|
|
||||||
const validator = location === 'param' ? param(field) : body(field);
|
|
||||||
return validator.isUUID().withMessage(`${field} must be a valid UUID`);
|
|
||||||
},
|
|
||||||
|
|
||||||
requiredString: (field, min = 1, max = 255) =>
|
|
||||||
body(field)
|
|
||||||
.trim()
|
|
||||||
.notEmpty()
|
|
||||||
.withMessage(`${field} is required`)
|
|
||||||
.isLength({ min, max })
|
|
||||||
.withMessage(`${field} must be ${min}-${max} characters`),
|
|
||||||
|
|
||||||
optionalString: (field, max = 255) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.trim()
|
|
||||||
.isLength({ max })
|
|
||||||
.withMessage(`${field} must be at most ${max} characters`),
|
|
||||||
|
|
||||||
slug: (field) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.trim()
|
|
||||||
.matches(/^[a-z0-9_-]+$/i)
|
|
||||||
.withMessage(
|
|
||||||
`${field} can only contain letters, numbers, dashes, underscores`,
|
|
||||||
),
|
|
||||||
|
|
||||||
email: (field) =>
|
|
||||||
body(field)
|
|
||||||
.trim()
|
|
||||||
.isEmail()
|
|
||||||
.withMessage('Must be a valid email')
|
|
||||||
.normalizeEmail(),
|
|
||||||
|
|
||||||
optionalEmail: (field) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.trim()
|
|
||||||
.isEmail()
|
|
||||||
.withMessage('Must be a valid email')
|
|
||||||
.normalizeEmail(),
|
|
||||||
|
|
||||||
enum: (field, values) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.isIn(values)
|
|
||||||
.withMessage(`${field} must be one of: ${values.join(', ')}`),
|
|
||||||
|
|
||||||
boolean: (field) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.isBoolean()
|
|
||||||
.withMessage(`${field} must be a boolean`),
|
|
||||||
|
|
||||||
integer: (field, min, max) => {
|
|
||||||
let validator = body(field).optional().isInt();
|
|
||||||
if (min !== undefined)
|
|
||||||
validator = validator
|
|
||||||
.custom((val) => val >= min)
|
|
||||||
.withMessage(`${field} must be at least ${min}`);
|
|
||||||
if (max !== undefined)
|
|
||||||
validator = validator
|
|
||||||
.custom((val) => val <= max)
|
|
||||||
.withMessage(`${field} must be at most ${max}`);
|
|
||||||
return validator;
|
|
||||||
},
|
|
||||||
|
|
||||||
pagination: () => [
|
|
||||||
query('page').optional().isInt({ min: 0 }).toInt(),
|
|
||||||
query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
|
|
||||||
],
|
|
||||||
|
|
||||||
url: (field) =>
|
|
||||||
body(field)
|
|
||||||
.optional()
|
|
||||||
.trim()
|
|
||||||
.isURL()
|
|
||||||
.withMessage(`${field} must be a valid URL`),
|
|
||||||
};
|
|
||||||
|
|
||||||
function createEntityValidation(entityConfig = {}) {
|
|
||||||
const { fields = [], requiredFields = [] } = entityConfig;
|
|
||||||
|
|
||||||
const fieldValidators = fields.map((field) => {
|
|
||||||
if (requiredFields.includes(field.name)) {
|
|
||||||
return validators.requiredString(
|
|
||||||
`data.${field.name}`,
|
|
||||||
field.min || 1,
|
|
||||||
field.max || 255,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return validators.optionalString(`data.${field.name}`, field.max || 255);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
create: [...fieldValidators, handleValidationErrors],
|
|
||||||
update: [validators.uuid('id'), ...fieldValidators, handleValidationErrors],
|
|
||||||
delete: [validators.uuid('id'), handleValidationErrors],
|
|
||||||
get: [validators.uuid('id'), handleValidationErrors],
|
|
||||||
list: [...validators.pagination(), handleValidationErrors],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { handleValidationErrors, validators, createEntityValidation };
|
|
||||||
@ -6,50 +6,13 @@ const AuthService = require('../services/auth');
|
|||||||
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
||||||
const EmailSender = require('../services/email');
|
const EmailSender = require('../services/email');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
const {
|
||||||
|
authLimiter: signinLimiter,
|
||||||
|
signupLimiter,
|
||||||
|
passwordResetLimiter,
|
||||||
|
} = require('../middlewares/rateLimiter');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const authRateLimitStore = new Map();
|
|
||||||
|
|
||||||
const createMemoryRateLimiter =
|
|
||||||
({ keyPrefix, maxRequests, windowMs }) =>
|
|
||||||
(req, res, next) => {
|
|
||||||
const key = `${keyPrefix}:${req.ip || 'unknown'}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const current = authRateLimitStore.get(key);
|
|
||||||
|
|
||||||
if (!current || current.expiresAt <= now) {
|
|
||||||
authRateLimitStore.set(key, { count: 1, expiresAt: now + windowMs });
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.count >= maxRequests) {
|
|
||||||
return res.status(429).send({
|
|
||||||
message: 'Too many requests. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
current.count += 1;
|
|
||||||
authRateLimitStore.set(key, current);
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
|
|
||||||
const signinLimiter = createMemoryRateLimiter({
|
|
||||||
keyPrefix: 'signin',
|
|
||||||
maxRequests: 10,
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signupLimiter = createMemoryRateLimiter({
|
|
||||||
keyPrefix: 'signup',
|
|
||||||
maxRequests: 5,
|
|
||||||
windowMs: 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const passwordResetLimiter = createMemoryRateLimiter({
|
|
||||||
keyPrefix: 'password-reset',
|
|
||||||
maxRequests: 5,
|
|
||||||
windowMs: 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
function safeParseUrl(value) {
|
function safeParseUrl(value) {
|
||||||
if (!value || typeof value !== 'string') {
|
if (!value || typeof value !== 'string') {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const services = require('../services/file');
|
const services = require('../services/file/');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// JSON body parser that ONLY parses application/json content-type
|
// JSON body parser that ONLY parses application/json content-type
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
88
backend/src/services/file/BaseStorageProvider.js
Normal file
88
backend/src/services/file/BaseStorageProvider.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* BaseStorageProvider
|
||||||
|
*
|
||||||
|
* Abstract base class for storage providers (Strategy Pattern).
|
||||||
|
* Subclasses implement specific storage backends (S3, GCloud, Local).
|
||||||
|
*/
|
||||||
|
|
||||||
|
class BaseStorageProvider {
|
||||||
|
/**
|
||||||
|
* Provider name for identification
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
static get providerName() {
|
||||||
|
throw new Error('providerName must be defined in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to storage
|
||||||
|
* @param {string} _key - Storage key/path
|
||||||
|
* @param {Buffer|ReadableStream} _data - File data
|
||||||
|
* @param {Object} _options - Upload options
|
||||||
|
* @param {string} [_options.contentType] - MIME type
|
||||||
|
* @param {Object} [_options.metadata] - Additional metadata
|
||||||
|
* @returns {Promise<{ key: string, url?: string }>}
|
||||||
|
*/
|
||||||
|
async upload(_key, _data, _options) {
|
||||||
|
throw new Error('upload must be implemented in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from storage
|
||||||
|
* @param {string} _key - Storage key/path
|
||||||
|
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
|
||||||
|
*/
|
||||||
|
async download(_key) {
|
||||||
|
throw new Error('download must be implemented in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from storage
|
||||||
|
* @param {string} _key - Storage key/path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async delete(_key) {
|
||||||
|
throw new Error('delete must be implemented in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists
|
||||||
|
* @param {string} _key - Storage key/path
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async exists(_key) {
|
||||||
|
throw new Error('exists must be implemented in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files with a given prefix
|
||||||
|
* @param {string} _prefix - Key prefix
|
||||||
|
* @returns {Promise<string[]>} Array of keys
|
||||||
|
*/
|
||||||
|
async list(_prefix) {
|
||||||
|
throw new Error('list must be implemented in subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL for direct access (if supported)
|
||||||
|
* @param {string} _key - Storage key/path
|
||||||
|
* @param {number} _expiresIn - Expiration time in seconds
|
||||||
|
* @returns {Promise<string|null>} Signed URL or null if not supported
|
||||||
|
*/
|
||||||
|
async getSignedUrl(_key, _expiresIn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple files
|
||||||
|
* @param {string[]} keys - Array of keys to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteMany(keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseStorageProvider;
|
||||||
221
backend/src/services/file/LocalStorageProvider.js
Normal file
221
backend/src/services/file/LocalStorageProvider.js
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* LocalStorageProvider
|
||||||
|
*
|
||||||
|
* Local filesystem storage implementation following the Strategy Pattern.
|
||||||
|
* Implements BaseStorageProvider interface for local disk operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { pipeline } = require('stream/promises');
|
||||||
|
const BaseStorageProvider = require('./BaseStorageProvider');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure directory exists for a file path
|
||||||
|
*/
|
||||||
|
const ensureDirectoryExistence = (filePath) => {
|
||||||
|
const dirname = path.dirname(filePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(dirname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirectoryExistence(dirname);
|
||||||
|
fs.mkdirSync(dirname);
|
||||||
|
};
|
||||||
|
|
||||||
|
class LocalStorageProvider extends BaseStorageProvider {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.basePath - Base directory for file storage
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
this.basePath = options.basePath || './uploads';
|
||||||
|
|
||||||
|
// Ensure base path exists
|
||||||
|
if (!fs.existsSync(this.basePath)) {
|
||||||
|
fs.mkdirSync(this.basePath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get providerName() {
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full file path
|
||||||
|
*/
|
||||||
|
buildPath(key) {
|
||||||
|
const cleanKey = (key || '').replace(/^\/+/, '');
|
||||||
|
return path.join(this.basePath, cleanKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to local storage
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {Buffer|ReadableStream} data - File data
|
||||||
|
* @param {Object} _options - Upload options (not used for local)
|
||||||
|
* @returns {Promise<{ key: string }>}
|
||||||
|
*/
|
||||||
|
async upload(key, data, _options = {}) {
|
||||||
|
const filePath = this.buildPath(key);
|
||||||
|
ensureDirectoryExistence(filePath);
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
fs.writeFileSync(filePath, data);
|
||||||
|
} else if (data && typeof data.pipe === 'function') {
|
||||||
|
// Handle stream
|
||||||
|
const writeStream = fs.createWriteStream(filePath);
|
||||||
|
await pipeline(data, writeStream);
|
||||||
|
} else {
|
||||||
|
throw new Error('Data must be a Buffer or ReadableStream');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from local storage
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
|
||||||
|
*/
|
||||||
|
async download(key) {
|
||||||
|
const filePath = this.buildPath(key);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`File not found: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
// Try to determine content type from extension
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = this.getContentType(ext);
|
||||||
|
|
||||||
|
return { body, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from local storage
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async delete(key) {
|
||||||
|
const filePath = this.buildPath(key);
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple files from local storage
|
||||||
|
* @param {string[]} keys - Array of keys to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteMany(keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists in local storage
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async exists(key) {
|
||||||
|
const filePath = this.buildPath(key);
|
||||||
|
return fs.existsSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files with a given prefix
|
||||||
|
* @param {string} prefix - Key prefix (directory path)
|
||||||
|
* @returns {Promise<string[]>} Array of keys
|
||||||
|
*/
|
||||||
|
async list(prefix) {
|
||||||
|
const dirPath = this.buildPath(prefix);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(dirPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return [prefix];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
const readDir = (dir, relativePath = '') => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(relativePath, entry.name);
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
readDir(fullPath, entryPath);
|
||||||
|
} else {
|
||||||
|
files.push(path.join(prefix, entryPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
readDir(dirPath);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL for direct access (not supported for local storage)
|
||||||
|
* For local storage, return the file path that can be served by express.static
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {number} _expiresIn - Expiration time (ignored for local)
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
async getSignedUrl(key, _expiresIn) {
|
||||||
|
// Local storage doesn't support signed URLs
|
||||||
|
// Return the relative path that can be served by a static file server
|
||||||
|
return `/uploads/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base path
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getBasePath() {
|
||||||
|
return this.basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content type from file extension
|
||||||
|
* @param {string} ext - File extension
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getContentType(ext) {
|
||||||
|
const types = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
};
|
||||||
|
|
||||||
|
return types[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LocalStorageProvider;
|
||||||
256
backend/src/services/file/S3StorageProvider.js
Normal file
256
backend/src/services/file/S3StorageProvider.js
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* S3StorageProvider
|
||||||
|
*
|
||||||
|
* AWS S3 storage implementation following the Strategy Pattern.
|
||||||
|
* Implements BaseStorageProvider interface for S3-specific operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
DeleteObjectsCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
|
} = require('@aws-sdk/client-s3');
|
||||||
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
const BaseStorageProvider = require('./BaseStorageProvider');
|
||||||
|
|
||||||
|
class S3StorageProvider extends BaseStorageProvider {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.bucket - S3 bucket name
|
||||||
|
* @param {string} options.region - AWS region
|
||||||
|
* @param {string} [options.accessKeyId] - AWS access key ID
|
||||||
|
* @param {string} [options.secretAccessKey] - AWS secret access key
|
||||||
|
* @param {string} [options.prefix] - Key prefix for all operations
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
this.bucket = options.bucket;
|
||||||
|
this.prefix = options.prefix || '';
|
||||||
|
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: options.region || 'us-east-1',
|
||||||
|
credentials:
|
||||||
|
options.accessKeyId && options.secretAccessKey
|
||||||
|
? {
|
||||||
|
accessKeyId: options.accessKeyId,
|
||||||
|
secretAccessKey: options.secretAccessKey,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static get providerName() {
|
||||||
|
return 's3';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full key with prefix
|
||||||
|
*/
|
||||||
|
buildKey(key) {
|
||||||
|
const cleanPrefix = (this.prefix || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
const cleanKey = (key || '').replace(/^\/+/, '');
|
||||||
|
return cleanPrefix ? `${cleanPrefix}/${cleanKey}` : cleanKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to S3
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {Buffer|ReadableStream} data - File data
|
||||||
|
* @param {Object} options - Upload options
|
||||||
|
* @returns {Promise<{ key: string, url?: string }>}
|
||||||
|
*/
|
||||||
|
async upload(key, data, options = {}) {
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
Body: data,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.contentType) {
|
||||||
|
params.ContentType = options.contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.metadata) {
|
||||||
|
params.Metadata = options.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.send(new PutObjectCommand(params));
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: fullKey,
|
||||||
|
url: `https://${this.bucket}.s3.amazonaws.com/${fullKey}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from S3
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<{ body: ReadableStream, contentType?: string }>}
|
||||||
|
*/
|
||||||
|
async download(key) {
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
const output = await this.client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: output.Body,
|
||||||
|
contentType: output.ContentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from S3
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async delete(key) {
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple files from S3
|
||||||
|
* @param {string[]} keys - Array of keys to delete
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async deleteMany(keys) {
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects = keys.map((key) => ({ Key: this.buildKey(key) }));
|
||||||
|
|
||||||
|
// S3 DeleteObjects supports max 1000 objects per request
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < objects.length; i += 1000) {
|
||||||
|
chunks.push(objects.slice(i, i + 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
await this.client.send(
|
||||||
|
new DeleteObjectsCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Delete: { Objects: chunk },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file exists in S3
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async exists(key) {
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client.send(
|
||||||
|
new HeadObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'NotFound' || error.name === 'NoSuchKey') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files with a given prefix
|
||||||
|
* @param {string} prefix - Key prefix
|
||||||
|
* @returns {Promise<string[]>} Array of keys
|
||||||
|
*/
|
||||||
|
async list(prefix) {
|
||||||
|
const fullPrefix = this.buildKey(prefix);
|
||||||
|
const keys = [];
|
||||||
|
let continuationToken = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = {
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Prefix: fullPrefix,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (continuationToken) {
|
||||||
|
params.ContinuationToken = continuationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.client.send(new ListObjectsV2Command(params));
|
||||||
|
|
||||||
|
if (result.Contents) {
|
||||||
|
keys.push(...result.Contents.map((obj) => obj.Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = result.IsTruncated
|
||||||
|
? result.NextContinuationToken
|
||||||
|
: null;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a signed URL for direct access
|
||||||
|
* @param {string} key - Storage key/path
|
||||||
|
* @param {number} expiresIn - Expiration time in seconds
|
||||||
|
* @returns {Promise<string>} Signed URL
|
||||||
|
*/
|
||||||
|
async getSignedUrl(key, expiresIn = 3600) {
|
||||||
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return getSignedUrl(this.client, command, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying S3 client for advanced operations
|
||||||
|
* @returns {S3Client}
|
||||||
|
*/
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bucket name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getBucket() {
|
||||||
|
return this.bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get prefix
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getPrefix() {
|
||||||
|
return this.prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = S3StorageProvider;
|
||||||
252
backend/src/services/file/UploadSessionManager.js
Normal file
252
backend/src/services/file/UploadSessionManager.js
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* UploadSessionManager
|
||||||
|
*
|
||||||
|
* Manages chunked upload sessions for large file uploads.
|
||||||
|
* Handles session lifecycle, chunk tracking, and assembly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { v4: uuid } = require('uuid');
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure directory exists
|
||||||
|
*/
|
||||||
|
const ensureDirectoryExistence = (filePath) => {
|
||||||
|
const dirname = path.dirname(filePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(dirname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirectoryExistence(dirname);
|
||||||
|
fs.mkdirSync(dirname);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append one file to another
|
||||||
|
*/
|
||||||
|
const streamAppendFile = async (targetPath, sourcePath) => {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const writeStream = fs.createWriteStream(targetPath, { flags: 'a' });
|
||||||
|
const readStream = fs.createReadStream(sourcePath);
|
||||||
|
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
readStream.on('error', reject);
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
readStream.pipe(writeStream, { end: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
class UploadSessionManager {
|
||||||
|
/**
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.sessionDir - Base directory for upload sessions
|
||||||
|
* @param {number} [options.ttlMs] - Session TTL in milliseconds
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.sessionDir = options.sessionDir;
|
||||||
|
this.ttlMs = options.ttlMs || DEFAULT_TTL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session directory path
|
||||||
|
*/
|
||||||
|
getSessionDir(sessionId) {
|
||||||
|
return path.join(this.sessionDir, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session metadata path
|
||||||
|
*/
|
||||||
|
getMetaPath(sessionId) {
|
||||||
|
return path.join(this.getSessionDir(sessionId), 'meta.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session chunks directory
|
||||||
|
*/
|
||||||
|
getChunksDir(sessionId) {
|
||||||
|
return path.join(this.getSessionDir(sessionId), 'chunks');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chunk file path
|
||||||
|
*/
|
||||||
|
getChunkPath(sessionId, chunkIndex) {
|
||||||
|
return path.join(this.getChunksDir(sessionId), `${String(chunkIndex)}.part`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read session metadata
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
readMeta(sessionId) {
|
||||||
|
const metaPath = this.getMetaPath(sessionId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(metaPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(metaPath, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write session metadata
|
||||||
|
*/
|
||||||
|
writeMeta(sessionId, payload) {
|
||||||
|
const metaPath = this.getMetaPath(sessionId);
|
||||||
|
ensureDirectoryExistence(metaPath);
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new upload session
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.filename - Original filename
|
||||||
|
* @param {string} options.folder - Target folder
|
||||||
|
* @param {number} options.totalChunks - Total number of chunks
|
||||||
|
* @param {number} [options.totalSize] - Total file size
|
||||||
|
* @param {string} [options.userId] - User ID
|
||||||
|
* @param {string} [options.contentType] - File content type
|
||||||
|
* @returns {string} Session ID
|
||||||
|
*/
|
||||||
|
createSession(options) {
|
||||||
|
const sessionId = uuid();
|
||||||
|
const chunksDir = this.getChunksDir(sessionId);
|
||||||
|
|
||||||
|
ensureDirectoryExistence(chunksDir);
|
||||||
|
fs.mkdirSync(chunksDir, { recursive: true });
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const meta = {
|
||||||
|
sessionId,
|
||||||
|
filename: options.filename,
|
||||||
|
folder: options.folder,
|
||||||
|
totalChunks: options.totalChunks,
|
||||||
|
totalSize: options.totalSize || 0,
|
||||||
|
userId: options.userId || null,
|
||||||
|
contentType: options.contentType || null,
|
||||||
|
uploadedChunks: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.writeMeta(sessionId, meta);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a chunk
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {number} chunkIndex
|
||||||
|
* @param {Buffer} data
|
||||||
|
*/
|
||||||
|
async saveChunk(sessionId, chunkIndex, data) {
|
||||||
|
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
|
||||||
|
ensureDirectoryExistence(chunkPath);
|
||||||
|
fs.writeFileSync(chunkPath, data);
|
||||||
|
|
||||||
|
const meta = this.readMeta(sessionId);
|
||||||
|
if (meta) {
|
||||||
|
meta.uploadedChunks[chunkIndex] = {
|
||||||
|
size: data.length,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
meta.updatedAt = new Date().toISOString();
|
||||||
|
this.writeMeta(sessionId, meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a chunk exists
|
||||||
|
*/
|
||||||
|
chunkExists(sessionId, chunkIndex) {
|
||||||
|
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
|
||||||
|
return fs.existsSync(chunkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is complete
|
||||||
|
*/
|
||||||
|
isComplete(sessionId) {
|
||||||
|
const meta = this.readMeta(sessionId);
|
||||||
|
if (!meta) return false;
|
||||||
|
|
||||||
|
const uploadedCount = Object.keys(meta.uploadedChunks).length;
|
||||||
|
return uploadedCount >= meta.totalChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble chunks into final file
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {string} targetPath - Path for assembled file
|
||||||
|
*/
|
||||||
|
async assembleChunks(sessionId, targetPath) {
|
||||||
|
const meta = this.readMeta(sessionId);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Error('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirectoryExistence(targetPath);
|
||||||
|
|
||||||
|
// Create empty target file
|
||||||
|
fs.writeFileSync(targetPath, '');
|
||||||
|
|
||||||
|
// Append chunks in order
|
||||||
|
for (let i = 0; i < meta.totalChunks; i++) {
|
||||||
|
const chunkPath = this.getChunkPath(sessionId, i);
|
||||||
|
if (!fs.existsSync(chunkPath)) {
|
||||||
|
throw new Error(`Missing chunk ${i}`);
|
||||||
|
}
|
||||||
|
await streamAppendFile(targetPath, chunkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an upload session
|
||||||
|
*/
|
||||||
|
removeSession(sessionId) {
|
||||||
|
const sessionDir = this.getSessionDir(sessionId);
|
||||||
|
|
||||||
|
if (fs.existsSync(sessionDir)) {
|
||||||
|
fs.rmSync(sessionDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup expired sessions
|
||||||
|
*/
|
||||||
|
cleanupExpiredSessions() {
|
||||||
|
if (!fs.existsSync(this.sessionDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionIds = fs.readdirSync(this.sessionDir);
|
||||||
|
|
||||||
|
sessionIds.forEach((sessionId) => {
|
||||||
|
try {
|
||||||
|
const meta = this.readMeta(sessionId);
|
||||||
|
if (!meta) {
|
||||||
|
this.removeSession(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date(meta.updatedAt || meta.createdAt || 0).getTime();
|
||||||
|
if (!updatedAt || now - updatedAt > this.ttlMs) {
|
||||||
|
this.removeSession(sessionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.removeSession(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UploadSessionManager;
|
||||||
27
backend/src/services/file/index.js
Normal file
27
backend/src/services/file/index.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* File Service Module
|
||||||
|
*
|
||||||
|
* Modular file storage service with Strategy Pattern providers:
|
||||||
|
* - Local filesystem (LocalStorageProvider)
|
||||||
|
* - AWS S3 (S3StorageProvider)
|
||||||
|
* - Google Cloud Storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BaseStorageProvider = require('./BaseStorageProvider');
|
||||||
|
const S3StorageProvider = require('./S3StorageProvider');
|
||||||
|
const LocalStorageProvider = require('./LocalStorageProvider');
|
||||||
|
const UploadSessionManager = require('./UploadSessionManager');
|
||||||
|
|
||||||
|
// Re-export the unified file service
|
||||||
|
const FileService = require('../file');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Unified service API
|
||||||
|
...FileService,
|
||||||
|
|
||||||
|
// Storage providers (for direct usage if needed)
|
||||||
|
BaseStorageProvider,
|
||||||
|
S3StorageProvider,
|
||||||
|
LocalStorageProvider,
|
||||||
|
UploadSessionManager,
|
||||||
|
};
|
||||||
@ -1,95 +0,0 @@
|
|||||||
const { logger } = require('./logger');
|
|
||||||
|
|
||||||
class CircuitBreaker {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.name = options.name || 'default';
|
|
||||||
this.failureThreshold = options.failureThreshold || 5;
|
|
||||||
this.resetTimeout = options.resetTimeout || 30000;
|
|
||||||
this.failures = 0;
|
|
||||||
this.state = 'CLOSED';
|
|
||||||
this.nextAttempt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(fn) {
|
|
||||||
if (this.state === 'OPEN') {
|
|
||||||
if (Date.now() < this.nextAttempt) {
|
|
||||||
logger.warn(
|
|
||||||
{ circuitBreaker: this.name, state: this.state },
|
|
||||||
'Circuit breaker is OPEN, rejecting request',
|
|
||||||
);
|
|
||||||
throw new Error(`Circuit breaker ${this.name} is OPEN`);
|
|
||||||
}
|
|
||||||
this.state = 'HALF-OPEN';
|
|
||||||
logger.info(
|
|
||||||
{ circuitBreaker: this.name },
|
|
||||||
'Circuit breaker moved to HALF-OPEN',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fn();
|
|
||||||
this.onSuccess();
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.onFailure(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess() {
|
|
||||||
if (this.state === 'HALF-OPEN') {
|
|
||||||
logger.info(
|
|
||||||
{ circuitBreaker: this.name },
|
|
||||||
'Circuit breaker recovered, moving to CLOSED',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.failures = 0;
|
|
||||||
this.state = 'CLOSED';
|
|
||||||
}
|
|
||||||
|
|
||||||
onFailure(error) {
|
|
||||||
this.failures++;
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
circuitBreaker: this.name,
|
|
||||||
failures: this.failures,
|
|
||||||
threshold: this.failureThreshold,
|
|
||||||
error: error.message,
|
|
||||||
},
|
|
||||||
'Circuit breaker recorded failure',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.failures >= this.failureThreshold) {
|
|
||||||
this.state = 'OPEN';
|
|
||||||
this.nextAttempt = Date.now() + this.resetTimeout;
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
circuitBreaker: this.name,
|
|
||||||
resetAt: new Date(this.nextAttempt).toISOString(),
|
|
||||||
},
|
|
||||||
'Circuit breaker tripped to OPEN',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getState() {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
state: this.state,
|
|
||||||
failures: this.failures,
|
|
||||||
nextAttempt:
|
|
||||||
this.state === 'OPEN' ? new Date(this.nextAttempt).toISOString() : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const circuitBreakers = new Map();
|
|
||||||
|
|
||||||
function getCircuitBreaker(name, options = {}) {
|
|
||||||
if (!circuitBreakers.has(name)) {
|
|
||||||
circuitBreakers.set(name, new CircuitBreaker({ name, ...options }));
|
|
||||||
}
|
|
||||||
return circuitBreakers.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { CircuitBreaker, getCircuitBreaker };
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
const EventEmitter = require('events');
|
|
||||||
const { logger } = require('./logger');
|
|
||||||
|
|
||||||
class AppEventEmitter extends EventEmitter {
|
|
||||||
emit(event, data) {
|
|
||||||
logger.debug(
|
|
||||||
{ event, hasListeners: this.listenerCount(event) > 0 },
|
|
||||||
'Event emitted',
|
|
||||||
);
|
|
||||||
return super.emit(event, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async emitAsync(event, data) {
|
|
||||||
logger.debug(
|
|
||||||
{ event, listenerCount: this.listenerCount(event) },
|
|
||||||
'Async event emitted',
|
|
||||||
);
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
this.listeners(event).map((listener) => listener(data)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const failures = results.filter((r) => r.status === 'rejected');
|
|
||||||
if (failures.length > 0) {
|
|
||||||
logger.warn(
|
|
||||||
{ event, failures: failures.map((f) => f.reason?.message) },
|
|
||||||
'Some event listeners failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
onAsync(event, listener) {
|
|
||||||
this.on(event, async (data) => {
|
|
||||||
try {
|
|
||||||
await listener(data);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
{ event, error: error.message },
|
|
||||||
'Async event listener error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const appEvents = new AppEventEmitter();
|
|
||||||
|
|
||||||
appEvents.on('user.created', (user) => {
|
|
||||||
logger.info(
|
|
||||||
{ userId: user.id, email: user.email },
|
|
||||||
'User created event received',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
appEvents.on('project.created', (project) => {
|
|
||||||
logger.info(
|
|
||||||
{ projectId: project.id, name: project.name },
|
|
||||||
'Project created event received',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
appEvents.on('project.published', (project) => {
|
|
||||||
logger.info(
|
|
||||||
{ projectId: project.id, slug: project.slug },
|
|
||||||
'Project published event received',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
appEvents.on('error', (error) => {
|
|
||||||
logger.error({ error: error.message }, 'Application event error');
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = { appEvents, AppEventEmitter };
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
...require('./errors'),
|
...require('./errors'),
|
||||||
...require('./logger'),
|
...require('./logger'),
|
||||||
...require('./circuit-breaker'),
|
|
||||||
...require('./events'),
|
|
||||||
envValidation: require('./env-validation'),
|
envValidation: require('./env-validation'),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -59,9 +59,10 @@ frontend/src/
|
|||||||
│ ├── register.tsx # Registration page
|
│ ├── register.tsx # Registration page
|
||||||
│ ├── dashboard.tsx # Main dashboard
|
│ ├── dashboard.tsx # Main dashboard
|
||||||
│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements)
|
│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements)
|
||||||
│ ├── runtime.tsx # Tour playback viewer
|
|
||||||
│ ├── search.tsx # Global search results
|
│ ├── search.tsx # Global search results
|
||||||
│ ├── p/[slug].tsx # Public tour pages (PWA-enabled)
|
│ ├── p/[projectSlug]/ # Public tour pages (PWA-enabled)
|
||||||
|
│ │ ├── index.tsx # Production presentation
|
||||||
|
│ │ └── stage.tsx # Stage preview
|
||||||
│ ├── projects/ # Project CRUD pages
|
│ ├── projects/ # Project CRUD pages
|
||||||
│ ├── tour_pages/ # Tour page management
|
│ ├── tour_pages/ # Tour page management
|
||||||
│ ├── assets/ # Asset library
|
│ ├── assets/ # Asset library
|
||||||
@ -116,10 +117,10 @@ frontend/src/
|
|||||||
│ ├── mediaDuration.ts # Video/audio duration
|
│ ├── mediaDuration.ts # Video/audio duration
|
||||||
│ ├── assetUrl.ts # CDN URL resolution
|
│ ├── assetUrl.ts # CDN URL resolution
|
||||||
│ ├── extractPageLinks.ts # Extract navigation links from pages
|
│ ├── extractPageLinks.ts # Extract navigation links from pages
|
||||||
│ ├── StorageManager.ts # Cache API storage for assets
|
|
||||||
│ ├── parseJson.ts # Safe JSON parsing
|
│ ├── parseJson.ts # Safe JSON parsing
|
||||||
│ ├── logger.ts # Client-side logging
|
│ ├── logger.ts # Client-side logging
|
||||||
│ ├── offline/ # Offline utilities
|
│ ├── offline/ # Offline utilities
|
||||||
|
│ │ └── StorageManager.ts # Cache API storage for assets
|
||||||
│ └── offlineDb/ # IndexedDB (Dexie) setup
|
│ └── offlineDb/ # IndexedDB (Dexie) setup
|
||||||
│
|
│
|
||||||
├── layouts/ # Page layouts
|
├── layouts/ # Page layouts
|
||||||
@ -154,9 +155,9 @@ Visual tour builder with:
|
|||||||
- **Always shows `dev` environment content**
|
- **Always shows `dev` environment content**
|
||||||
- "Save to Stage" button copies dev → stage
|
- "Save to Stage" button copies dev → stage
|
||||||
|
|
||||||
### Runtime (`/runtime`)
|
### Runtime Presentation (`RuntimePresentation.tsx`)
|
||||||
|
|
||||||
Tour playback viewer with:
|
The tour playback is handled by the `RuntimePresentation` component (used in public tour pages), featuring:
|
||||||
- Full-screen presentation mode
|
- Full-screen presentation mode
|
||||||
- Video transitions between pages
|
- Video transitions between pages
|
||||||
- Forward/reverse playback
|
- Forward/reverse playback
|
||||||
@ -164,11 +165,11 @@ Tour playback viewer with:
|
|||||||
- Keyboard/touch navigation
|
- Keyboard/touch navigation
|
||||||
- Asset preloading with S3 presigned URLs
|
- Asset preloading with S3 presigned URLs
|
||||||
|
|
||||||
### Public Tours (`/p/[slug]`)
|
### Public Tours (`/p/[projectSlug]`)
|
||||||
|
|
||||||
PWA-enabled public tour access:
|
PWA-enabled public tour access:
|
||||||
- **`/p/[slug]`** - Shows `production` environment (published content)
|
- **`/p/[projectSlug]`** - Shows `production` environment (published content)
|
||||||
- **`/p/[slug]/stage`** - Shows `stage` environment (preview)
|
- **`/p/[projectSlug]/stage`** - Shows `stage` environment (preview)
|
||||||
- Offline support via Service Worker
|
- Offline support via Service Worker
|
||||||
- Asset caching in Cache API and IndexedDB
|
- Asset caching in Cache API and IndexedDB
|
||||||
- Direct S3 downloads via presigned URLs
|
- Direct S3 downloads via presigned URLs
|
||||||
@ -215,7 +216,9 @@ dispatch(create({ data: newProject }));
|
|||||||
| `useOfflineMode` | Detect offline/online status |
|
| `useOfflineMode` | Detect offline/online status |
|
||||||
| `usePWAPreload` | Preload assets for offline |
|
| `usePWAPreload` | Preload assets for offline |
|
||||||
| `useStorageQuota` | Monitor IndexedDB usage |
|
| `useStorageQuota` | Monitor IndexedDB usage |
|
||||||
| `useElementSettingsForm` | Element settings form state (~60 fields) |
|
|
||||||
|
**Component-specific hooks:**
|
||||||
|
- `useElementSettingsForm` (`components/ElementSettings/`) - Element settings form state (~60 fields)
|
||||||
|
|
||||||
## Element Types
|
## Element Types
|
||||||
|
|
||||||
@ -262,8 +265,8 @@ Tour pages have a three-tier environment model:
|
|||||||
| Environment | Route | Description |
|
| Environment | Route | Description |
|
||||||
|-------------|-------|-------------|
|
|-------------|-------|-------------|
|
||||||
| `dev` | `/constructor?projectId=` | Editing/draft content |
|
| `dev` | `/constructor?projectId=` | Editing/draft content |
|
||||||
| `stage` | `/p/[slug]/stage` | Pre-production review |
|
| `stage` | `/p/[projectSlug]/stage` | Pre-production review |
|
||||||
| `production` | `/p/[slug]` | Published public content |
|
| `production` | `/p/[projectSlug]` | Published public content |
|
||||||
|
|
||||||
**Publishing flow:** `dev` → `stage` → `production`
|
**Publishing flow:** `dev` → `stage` → `production`
|
||||||
|
|
||||||
@ -278,8 +281,8 @@ The `X-Runtime-Environment` header tells the backend which environment to query.
|
|||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# API URL (defaults to localhost:8080)
|
# Backend API URL (defaults to localhost:8080)
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
NEXT_PUBLIC_BACK_API=http://localhost:8080
|
||||||
|
|
||||||
# Frontend port (optional, default 3000)
|
# Frontend port (optional, default 3000)
|
||||||
FRONT_PORT=3000
|
FRONT_PORT=3000
|
||||||
|
|||||||
@ -21,10 +21,10 @@ const nextConfig = {
|
|||||||
position: 'bottom-left',
|
position: 'bottom-left',
|
||||||
},
|
},
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: false,
|
||||||
},
|
},
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: false,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Access Logs Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/access_logs/access_logsSlice';
|
} from '../../stores/access_logs/access_logsSlice';
|
||||||
import { loadColumns } from './configureAccess_logsCols';
|
import { loadColumns } from './configureAccess_logsCols';
|
||||||
import type { AccessLog } from '../../types/entities';
|
import type { AccessLog } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableAccess_logsProps {
|
const TableAccess_logs = createTableComponent<AccessLog>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'access_logs',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.access_logs,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableAccess_logs: React.FC<TableAccess_logsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<AccessLog>
|
|
||||||
entityName='access_logs'
|
|
||||||
sliceSelector={(state: RootState) => state.access_logs}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableAccess_logs;
|
export default TableAccess_logs;
|
||||||
|
|||||||
@ -1,159 +1,52 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const ACCESS_LOGS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'environment',
|
||||||
|
headerName: 'Environment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'user',
|
||||||
|
headerName: 'User',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'users',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'path', headerName: 'Path', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'ip_address',
|
||||||
|
headerName: 'IPaddress',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'user_agent',
|
||||||
|
headerName: 'Useragent',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'accessed_at',
|
||||||
|
headerName: 'Accessedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'access_logs',
|
||||||
export const loadColumns = async (
|
columns: ACCESS_LOGS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ACCESS_LOGS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: { id?: string }) => value?.id,
|
|
||||||
getOptionLabel: (value: { label?: string }) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string) =>
|
|
||||||
(typeof value === 'object' ? value?.id : value) ?? value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'environment',
|
|
||||||
headerName: 'Environment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'user',
|
|
||||||
headerName: 'User',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect',
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('users'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'path',
|
|
||||||
headerName: 'Path',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'ip_address',
|
|
||||||
headerName: 'IPaddress',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'user_agent',
|
|
||||||
headerName: 'Useragent',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'accessed_at',
|
|
||||||
headerName: 'Accessedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime',
|
|
||||||
valueGetter: (_value, row) => new Date(row.accessed_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions',
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/access_logs/access_logs-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/access_logs/access_logs-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Asset Variants Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/asset_variants/asset_variantsSlice';
|
} from '../../stores/asset_variants/asset_variantsSlice';
|
||||||
import { loadColumns } from './configureAsset_variantsCols';
|
import { loadColumns } from './configureAsset_variantsCols';
|
||||||
import type { AssetVariant } from '../../types/entities';
|
import type { AssetVariant } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableAsset_variantsProps {
|
const TableAsset_variants = createTableComponent<AssetVariant>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'asset_variants',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.asset_variants,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableAsset_variants: React.FC<TableAsset_variantsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<AssetVariant>
|
|
||||||
entityName='asset_variants'
|
|
||||||
sliceSelector={(state: RootState) => state.asset_variants}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableAsset_variants;
|
export default TableAsset_variants;
|
||||||
|
|||||||
@ -1,143 +1,40 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const ASSET_VARIANTS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'asset',
|
||||||
|
headerName: 'Asset',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'assets',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'variant_type',
|
||||||
|
headerName: 'Varianttype',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'cdn_url', headerName: 'CDNURL', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'width_px',
|
||||||
|
headerName: 'Width(px)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'height_px',
|
||||||
|
headerName: 'Height(px)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'size_mb', headerName: 'Size(MB)', type: 'number', editable: true },
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'asset_variants',
|
||||||
export const loadColumns = async (
|
columns: ASSET_VARIANTS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ASSET_VARIANTS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'asset',
|
|
||||||
headerName: 'Asset',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('assets'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'variant_type',
|
|
||||||
headerName: 'Varianttype',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'cdn_url',
|
|
||||||
headerName: 'CDNURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'width_px',
|
|
||||||
headerName: 'Width(px)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'height_px',
|
|
||||||
headerName: 'Height(px)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'size_mb',
|
|
||||||
headerName: 'Size(MB)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/asset_variants/asset_variants-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/asset_variants/asset_variants-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Assets Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/assets/assetsSlice';
|
} from '../../stores/assets/assetsSlice';
|
||||||
import { loadColumns } from './configureAssetsCols';
|
import { loadColumns } from './configureAssetsCols';
|
||||||
import type { Asset } from '../../types/entities';
|
import type { Asset } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableAssetsProps {
|
const TableAssets = createTableComponent<Asset>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'assets',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.assets,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableAssets: React.FC<TableAssetsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<Asset>
|
|
||||||
entityName='assets'
|
|
||||||
sliceSelector={(state: RootState) => state.assets}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableAssets;
|
export default TableAssets;
|
||||||
|
|||||||
@ -1,260 +1,74 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const ASSETS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'asset_type',
|
||||||
|
headerName: 'Asset format',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'type', headerName: 'Type', type: 'text', editable: true },
|
||||||
|
{ field: 'cdn_url', headerName: 'CDNURL', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'storage_key',
|
||||||
|
headerName: 'Storagekey',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'mime_type', headerName: 'MIMEtype', type: 'text', editable: true },
|
||||||
|
{ field: 'size_mb', headerName: 'Size(MB)', type: 'number', editable: true },
|
||||||
|
{
|
||||||
|
field: 'width_px',
|
||||||
|
headerName: 'Width(px)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'height_px',
|
||||||
|
headerName: 'Height(px)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'duration_sec',
|
||||||
|
headerName: 'Duration(sec)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'checksum', headerName: 'Checksum', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'is_public',
|
||||||
|
headerName: 'Ispublic',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'is_deleted',
|
||||||
|
headerName: 'Isdeleted',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'deleted_at_time',
|
||||||
|
headerName: 'Deletedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'assets',
|
||||||
export const loadColumns = async (
|
columns: ASSETS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ASSETS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'asset_type',
|
|
||||||
headerName: 'Asset format',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'type',
|
|
||||||
headerName: 'Type',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'cdn_url',
|
|
||||||
headerName: 'CDNURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'storage_key',
|
|
||||||
headerName: 'Storagekey',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'mime_type',
|
|
||||||
headerName: 'MIMEtype',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'size_mb',
|
|
||||||
headerName: 'Size(MB)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'width_px',
|
|
||||||
headerName: 'Width(px)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'height_px',
|
|
||||||
headerName: 'Height(px)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'duration_sec',
|
|
||||||
headerName: 'Duration(sec)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'checksum',
|
|
||||||
headerName: 'Checksum',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_public',
|
|
||||||
headerName: 'Ispublic',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_deleted',
|
|
||||||
headerName: 'Isdeleted',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'deleted_at_time',
|
|
||||||
headerName: 'Deletedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.deleted_at_time),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/assets/assets-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/assets/assets-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,171 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
Views,
|
|
||||||
momentLocalizer,
|
|
||||||
SlotInfo,
|
|
||||||
EventProps,
|
|
||||||
} from 'react-big-calendar';
|
|
||||||
import moment from 'moment';
|
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
|
||||||
import ListActionsPopover from './ListActionsPopover';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import { hasPermission } from '../helpers/userPermissions';
|
|
||||||
|
|
||||||
const localizer = momentLocalizer(moment);
|
|
||||||
|
|
||||||
type TEvent = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
start: Date;
|
|
||||||
end: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
events: any[];
|
|
||||||
handleDeleteAction: (id: string) => void;
|
|
||||||
handleCreateEventAction: (slotInfo: SlotInfo) => void;
|
|
||||||
onDateRangeChange: (range: { start: string; end: string }) => void;
|
|
||||||
entityName: string;
|
|
||||||
showField: string;
|
|
||||||
pathEdit?: string;
|
|
||||||
pathView?: string;
|
|
||||||
'start-data-key': string;
|
|
||||||
'end-data-key': string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BigCalendar = ({
|
|
||||||
events,
|
|
||||||
handleDeleteAction,
|
|
||||||
handleCreateEventAction,
|
|
||||||
onDateRangeChange,
|
|
||||||
entityName,
|
|
||||||
showField,
|
|
||||||
pathEdit,
|
|
||||||
pathView,
|
|
||||||
'start-data-key': startDataKey,
|
|
||||||
'end-data-key': endDataKey,
|
|
||||||
}: Props) => {
|
|
||||||
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
|
|
||||||
const prevRange = useRef<{ start: string; end: string } | null>(null);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
|
||||||
const hasUpdatePermission =
|
|
||||||
currentUser &&
|
|
||||||
hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
|
|
||||||
const hasCreatePermission =
|
|
||||||
currentUser &&
|
|
||||||
hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
|
|
||||||
|
|
||||||
const { defaultDate, scrollToTime } = useMemo(
|
|
||||||
() => ({
|
|
||||||
defaultDate: new Date(),
|
|
||||||
scrollToTime: new Date(1970, 1, 1, 6),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!events || !Array.isArray(events) || !events?.length) return;
|
|
||||||
|
|
||||||
const formattedEvents = events.map((event) => ({
|
|
||||||
...event,
|
|
||||||
start: new Date(event[startDataKey]),
|
|
||||||
end: new Date(event[endDataKey]),
|
|
||||||
title: event[showField],
|
|
||||||
}));
|
|
||||||
|
|
||||||
setMyEvents(formattedEvents);
|
|
||||||
}, [endDataKey, events, startDataKey, showField]);
|
|
||||||
|
|
||||||
const onRangeChange = (range: Date[] | { start: Date; end: Date }) => {
|
|
||||||
const newRange = { start: '', end: '' };
|
|
||||||
const format = 'YYYY-MM-DDTHH:mm';
|
|
||||||
|
|
||||||
if (Array.isArray(range)) {
|
|
||||||
newRange.start = moment(range[0]).format(format);
|
|
||||||
newRange.end = moment(range[range.length - 1]).format(format);
|
|
||||||
} else {
|
|
||||||
newRange.start = moment(range.start).format(format);
|
|
||||||
newRange.end = moment(range.end).format(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newRange.start === newRange.end) {
|
|
||||||
newRange.end = moment(newRange.end).add(1, 'days').format(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the range fits in the previous range
|
|
||||||
if (
|
|
||||||
prevRange.current &&
|
|
||||||
prevRange.current.start <= newRange.start &&
|
|
||||||
prevRange.current.end >= newRange.end
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prevRange.current = { start: newRange.start, end: newRange.end };
|
|
||||||
onDateRangeChange(newRange);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='h-[600px] p-4'>
|
|
||||||
<Calendar
|
|
||||||
defaultDate={defaultDate}
|
|
||||||
defaultView={Views.MONTH}
|
|
||||||
events={myEvents}
|
|
||||||
localizer={localizer}
|
|
||||||
selectable={hasCreatePermission}
|
|
||||||
onSelectSlot={handleCreateEventAction}
|
|
||||||
onRangeChange={onRangeChange}
|
|
||||||
scrollToTime={scrollToTime}
|
|
||||||
components={{
|
|
||||||
event: (props) => (
|
|
||||||
<MyCustomEvent
|
|
||||||
{...props}
|
|
||||||
onDelete={handleDeleteAction}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
pathEdit={pathEdit}
|
|
||||||
pathView={pathView}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MyCustomEvent = (
|
|
||||||
props: {
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
hasUpdatePermission: boolean;
|
|
||||||
pathEdit?: string;
|
|
||||||
pathView?: string;
|
|
||||||
} & EventProps<TEvent>,
|
|
||||||
) => {
|
|
||||||
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } =
|
|
||||||
props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'flex items-center justify-between relative'}>
|
|
||||||
<Link
|
|
||||||
href={`${pathView}${event.id}`}
|
|
||||||
className={'text-ellipsis overflow-hidden grow'}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Link>
|
|
||||||
<ListActionsPopover
|
|
||||||
className={'w-2 h-2 text-white'}
|
|
||||||
iconClassName={'text-white w-5'}
|
|
||||||
itemId={event.id}
|
|
||||||
onDelete={onDelete}
|
|
||||||
pathEdit={`${pathEdit}${event.id}`}
|
|
||||||
pathView={`${pathView}${event.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BigCalendar;
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
export const chartColors = {
|
|
||||||
default: {
|
|
||||||
primary: '#00D1B2',
|
|
||||||
info: '#209CEE',
|
|
||||||
danger: '#FF3860',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const randomChartData = (n: number) => {
|
|
||||||
const data = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
data.push(Math.round(Math.random() * 200));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const datasetObject = (color: string, points: number) => {
|
|
||||||
return {
|
|
||||||
fill: false,
|
|
||||||
borderColor: chartColors.default[color],
|
|
||||||
borderWidth: 2,
|
|
||||||
borderDash: [],
|
|
||||||
borderDashOffset: 0.0,
|
|
||||||
pointBackgroundColor: chartColors.default[color],
|
|
||||||
pointBorderColor: 'rgba(255,255,255,0)',
|
|
||||||
pointHoverBackgroundColor: chartColors.default[color],
|
|
||||||
pointBorderWidth: 20,
|
|
||||||
pointHoverRadius: 4,
|
|
||||||
pointHoverBorderWidth: 15,
|
|
||||||
pointRadius: 4,
|
|
||||||
data: randomChartData(points),
|
|
||||||
tension: 0.5,
|
|
||||||
cubicInterpolationMode: 'default',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sampleChartData = (points = 9) => {
|
|
||||||
const labels = [];
|
|
||||||
|
|
||||||
for (let i = 1; i <= points; i++) {
|
|
||||||
labels.push(`0${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
datasetObject('primary', points),
|
|
||||||
datasetObject('info', points),
|
|
||||||
datasetObject('danger', points),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Chart,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
LineController,
|
|
||||||
LinearScale,
|
|
||||||
CategoryScale,
|
|
||||||
Tooltip,
|
|
||||||
} from 'chart.js';
|
|
||||||
import { Line } from 'react-chartjs-2';
|
|
||||||
|
|
||||||
Chart.register(
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
LineController,
|
|
||||||
LinearScale,
|
|
||||||
CategoryScale,
|
|
||||||
Tooltip,
|
|
||||||
);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ChartLineSample = ({ data }) => {
|
|
||||||
return <Line options={options} data={data} className='h-96' />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChartLineSample;
|
|
||||||
301
frontend/src/components/DataGrid/configBuilderFactory.tsx
Normal file
301
frontend/src/components/DataGrid/configBuilderFactory.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import {
|
||||||
|
GridColDef,
|
||||||
|
GridRowParams,
|
||||||
|
GridRenderCellParams,
|
||||||
|
GridSingleSelectColDef,
|
||||||
|
} from '@mui/x-data-grid';
|
||||||
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
|
import DataGridMultiSelect from '../DataGridMultiSelect';
|
||||||
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
|
import { logger } from '../../lib/logger';
|
||||||
|
|
||||||
|
export interface ColumnMetadata {
|
||||||
|
field: string;
|
||||||
|
headerName: string;
|
||||||
|
type?:
|
||||||
|
| 'text'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
|
| 'number'
|
||||||
|
| 'relation'
|
||||||
|
| 'relationMany'
|
||||||
|
| 'singleSelectRelation'
|
||||||
|
| 'image'
|
||||||
|
| 'actions';
|
||||||
|
editable?: boolean;
|
||||||
|
sortable?: boolean;
|
||||||
|
width?: number;
|
||||||
|
flex?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
entityRef?: string;
|
||||||
|
displayField?: string;
|
||||||
|
renderCell?: (params: GridRenderCellParams) => React.ReactElement;
|
||||||
|
valueFormatter?: (value: unknown) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnBuilderConfig {
|
||||||
|
entityName: string;
|
||||||
|
entityPath?: string;
|
||||||
|
columns: ColumnMetadata[];
|
||||||
|
updatePermission?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLUMN_PROPS = {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
filterable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRelationOptions(
|
||||||
|
entityRef: string,
|
||||||
|
user: unknown,
|
||||||
|
): Promise<Array<{ id: string; label: string }>> {
|
||||||
|
const permissionKey = `READ_${entityRef.toUpperCase()}`;
|
||||||
|
if (!hasPermission(user, permissionKey)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios(`/${entityRef}/autocomplete?limit=100`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to fetch ${entityRef} options`,
|
||||||
|
error instanceof Error ? error : { error },
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormatter(
|
||||||
|
col: ColumnMetadata,
|
||||||
|
): ((params: { value: unknown }) => string) | undefined {
|
||||||
|
if (col.valueFormatter) {
|
||||||
|
const customFormatter = col.valueFormatter;
|
||||||
|
return ({ value }) => customFormatter(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (col.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return ({ value }) => dataFormatter.booleanFormatter(value);
|
||||||
|
case 'date':
|
||||||
|
return ({ value }) => dataFormatter.dateFormatter(value);
|
||||||
|
case 'datetime':
|
||||||
|
return ({ value }) => dataFormatter.dateTimeFormatter(value);
|
||||||
|
case 'relation':
|
||||||
|
return ({ value }) => {
|
||||||
|
const formatter =
|
||||||
|
dataFormatter[
|
||||||
|
`${col.entityRef}OneListFormatter` as keyof typeof dataFormatter
|
||||||
|
];
|
||||||
|
return typeof formatter === 'function'
|
||||||
|
? formatter(value)
|
||||||
|
: String(value || '');
|
||||||
|
};
|
||||||
|
case 'relationMany':
|
||||||
|
return ({ value }) => {
|
||||||
|
const formatter =
|
||||||
|
dataFormatter[
|
||||||
|
`${col.entityRef}ManyListFormatter` as keyof typeof dataFormatter
|
||||||
|
];
|
||||||
|
if (typeof formatter === 'function') {
|
||||||
|
const result = formatter(value);
|
||||||
|
return Array.isArray(result)
|
||||||
|
? result.join(', ')
|
||||||
|
: String(result || '');
|
||||||
|
}
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.map((v: { name?: string }) => v?.name || '').join(', ')
|
||||||
|
: '';
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumn(
|
||||||
|
col: ColumnMetadata,
|
||||||
|
hasUpdatePermission: boolean,
|
||||||
|
_entityPath: string,
|
||||||
|
valueOptionsMap: Map<string, Array<{ id: string; label: string }>>,
|
||||||
|
): GridColDef {
|
||||||
|
const baseColumn: GridColDef = {
|
||||||
|
...DEFAULT_COLUMN_PROPS,
|
||||||
|
field: col.field,
|
||||||
|
headerName: col.headerName,
|
||||||
|
editable:
|
||||||
|
col.editable !== undefined ? col.editable && hasUpdatePermission : false,
|
||||||
|
sortable: col.sortable !== undefined ? col.sortable : true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (col.flex !== undefined) baseColumn.flex = col.flex;
|
||||||
|
if (col.width !== undefined) baseColumn.width = col.width;
|
||||||
|
if (col.minWidth !== undefined) baseColumn.minWidth = col.minWidth;
|
||||||
|
|
||||||
|
const formatter = getFormatter(col);
|
||||||
|
if (formatter) {
|
||||||
|
baseColumn.valueFormatter = formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.renderCell) {
|
||||||
|
baseColumn.renderCell = col.renderCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'relation' || col.type === 'relationMany') {
|
||||||
|
baseColumn.type = 'singleSelect' as const;
|
||||||
|
baseColumn.sortable = false;
|
||||||
|
if (col.entityRef) {
|
||||||
|
const entityRef = col.entityRef;
|
||||||
|
baseColumn.renderEditCell = (params) => (
|
||||||
|
<DataGridMultiSelect {...params} entityName={entityRef} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'singleSelectRelation' && col.entityRef) {
|
||||||
|
const singleSelectColumn = baseColumn as GridSingleSelectColDef;
|
||||||
|
singleSelectColumn.type = 'singleSelect';
|
||||||
|
singleSelectColumn.sortable = false;
|
||||||
|
singleSelectColumn.getOptionValue = (value: { id?: string }) => value?.id;
|
||||||
|
singleSelectColumn.getOptionLabel = (value: { label?: string }) =>
|
||||||
|
value?.label;
|
||||||
|
singleSelectColumn.valueOptions = valueOptionsMap.get(col.entityRef) || [];
|
||||||
|
singleSelectColumn.valueGetter = (value: { id?: string } | string | null) =>
|
||||||
|
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
||||||
|
value;
|
||||||
|
return singleSelectColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'datetime') {
|
||||||
|
baseColumn.type = 'dateTime' as const;
|
||||||
|
baseColumn.valueGetter = (_value, row) => new Date(row[col.field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'number') {
|
||||||
|
baseColumn.type = 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'boolean') {
|
||||||
|
baseColumn.type = 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.type === 'image') {
|
||||||
|
baseColumn.sortable = false;
|
||||||
|
baseColumn.renderCell =
|
||||||
|
col.renderCell ||
|
||||||
|
((params: GridRenderCellParams) => {
|
||||||
|
const imageUrl =
|
||||||
|
params.value &&
|
||||||
|
Array.isArray(params.value) &&
|
||||||
|
params.value[0]?.publicUrl;
|
||||||
|
return imageUrl ? (
|
||||||
|
<span
|
||||||
|
role='img'
|
||||||
|
aria-label='thumbnail'
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
backgroundImage: `url(${imageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionsColumn(
|
||||||
|
onDelete: (id: string) => void,
|
||||||
|
entityPath: string,
|
||||||
|
hasUpdatePermission: boolean,
|
||||||
|
): GridColDef {
|
||||||
|
return {
|
||||||
|
field: 'actions',
|
||||||
|
type: 'actions' as const,
|
||||||
|
minWidth: 30,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
getActions: (params: GridRowParams) => [
|
||||||
|
<div key={params?.row?.id}>
|
||||||
|
<ListActionsPopover
|
||||||
|
onDelete={onDelete}
|
||||||
|
itemId={params?.row?.id}
|
||||||
|
pathEdit={`/${entityPath}/${entityPath}-edit/?id=${params?.row?.id}`}
|
||||||
|
pathView={`/${entityPath}/${entityPath}-view/?id=${params?.row?.id}`}
|
||||||
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildColumns(
|
||||||
|
config: ColumnBuilderConfig,
|
||||||
|
onDelete: (id: string) => void,
|
||||||
|
user: unknown,
|
||||||
|
): Promise<GridColDef[]> {
|
||||||
|
const entityPath = config.entityPath || config.entityName;
|
||||||
|
const updatePermission =
|
||||||
|
config.updatePermission || `UPDATE_${config.entityName.toUpperCase()}`;
|
||||||
|
const hasUpdatePermission = hasPermission(user, updatePermission);
|
||||||
|
|
||||||
|
// Collect all singleSelectRelation entityRefs and fetch their options
|
||||||
|
const singleSelectRelations = config.columns
|
||||||
|
.filter((col) => col.type === 'singleSelectRelation' && col.entityRef)
|
||||||
|
.map((col) => col.entityRef as string);
|
||||||
|
|
||||||
|
const uniqueEntityRefs = Array.from(new Set(singleSelectRelations));
|
||||||
|
const valueOptionsMap = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ id: string; label: string }>
|
||||||
|
>();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
uniqueEntityRefs.map(async (entityRef) => {
|
||||||
|
const options = await fetchRelationOptions(entityRef, user);
|
||||||
|
valueOptionsMap.set(entityRef, options);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataColumns = config.columns
|
||||||
|
.filter((col) => col.type !== 'actions')
|
||||||
|
.map((col) =>
|
||||||
|
buildColumn(col, hasUpdatePermission, entityPath, valueOptionsMap),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActionsColumn = config.columns.some((col) => col.type === 'actions');
|
||||||
|
|
||||||
|
if (hasActionsColumn) {
|
||||||
|
const actionsColumn = buildActionsColumn(
|
||||||
|
onDelete,
|
||||||
|
entityPath,
|
||||||
|
hasUpdatePermission,
|
||||||
|
);
|
||||||
|
return [...dataColumns, actionsColumn];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createColumnLoader(config: ColumnBuilderConfig) {
|
||||||
|
return async (
|
||||||
|
onDelete: (id: string) => void,
|
||||||
|
_entityName: string,
|
||||||
|
user: unknown,
|
||||||
|
): Promise<GridColDef[]> => {
|
||||||
|
return buildColumns(config, onDelete, user);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default buildColumns;
|
||||||
118
frontend/src/components/Factory/createTableComponent.tsx
Normal file
118
frontend/src/components/Factory/createTableComponent.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Table Component Factory
|
||||||
|
*
|
||||||
|
* Generates entity table components from configuration.
|
||||||
|
* Reduces boilerplate in entity-specific table components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import GenericTable from '../Generic/GenericTable';
|
||||||
|
import type { RootState } from '../../stores/store';
|
||||||
|
import type { Filter, FilterItem } from '../../types/filters';
|
||||||
|
import type { GridColDef } from '@mui/x-data-grid';
|
||||||
|
import type { AsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import type { NotificationState } from '../../types/redux';
|
||||||
|
import type { BaseEntity } from '../../types/entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity slice state shape - matches InternalSliceState from createEntitySlice
|
||||||
|
*/
|
||||||
|
interface EntitySliceState<T> {
|
||||||
|
[key: string]: T[] | boolean | number | NotificationState | unknown[];
|
||||||
|
loading: boolean;
|
||||||
|
count: number;
|
||||||
|
refetch: boolean;
|
||||||
|
notify: NotificationState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for creating a table component
|
||||||
|
*/
|
||||||
|
export interface TableComponentConfig<T extends BaseEntity> {
|
||||||
|
/** Entity name (e.g., 'roles', 'users') */
|
||||||
|
entityName: string;
|
||||||
|
/** Redux slice selector */
|
||||||
|
sliceSelector: (state: RootState) => EntitySliceState<T>;
|
||||||
|
/** Fetch action thunk */
|
||||||
|
fetchAction: AsyncThunk<
|
||||||
|
T | { rows: T[]; count: number },
|
||||||
|
{ id?: string; query?: string },
|
||||||
|
object
|
||||||
|
>;
|
||||||
|
/** Update action thunk */
|
||||||
|
updateAction: AsyncThunk<T, { id: string; data: Partial<T> }, object>;
|
||||||
|
/** Delete single item action thunk */
|
||||||
|
deleteAction: AsyncThunk<void, string, object>;
|
||||||
|
/** Delete multiple items action thunk */
|
||||||
|
deleteByIdsAction: AsyncThunk<void, string[], object>;
|
||||||
|
/** Set refetch flag action */
|
||||||
|
setRefetchAction: (refetch: boolean) => { type: string; payload: boolean };
|
||||||
|
/** Column loader function */
|
||||||
|
loadColumnsFunction: (
|
||||||
|
onDelete: (id: string) => void,
|
||||||
|
entityName: string,
|
||||||
|
user: unknown,
|
||||||
|
) => Promise<GridColDef[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for generated table components
|
||||||
|
*/
|
||||||
|
export interface TableComponentProps {
|
||||||
|
filterItems: FilterItem[];
|
||||||
|
setFilterItems: (items: FilterItem[]) => void;
|
||||||
|
filters: Filter[];
|
||||||
|
showGrid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a table component from configuration
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/roles/rolesSlice';
|
||||||
|
* import { loadColumns } from './configureRolesCols';
|
||||||
|
*
|
||||||
|
* const TableRoles = createTableComponent<Role>({
|
||||||
|
* entityName: 'roles',
|
||||||
|
* sliceSelector: (state) => state.roles,
|
||||||
|
* fetchAction: fetch,
|
||||||
|
* updateAction: update,
|
||||||
|
* deleteAction: deleteItem,
|
||||||
|
* deleteByIdsAction: deleteItemsByIds,
|
||||||
|
* setRefetchAction: setRefetch,
|
||||||
|
* loadColumnsFunction: loadColumns,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* export default TableRoles;
|
||||||
|
*/
|
||||||
|
export function createTableComponent<T extends BaseEntity>(
|
||||||
|
config: TableComponentConfig<T>,
|
||||||
|
): React.FC<TableComponentProps> {
|
||||||
|
const TableComponent: React.FC<TableComponentProps> = ({
|
||||||
|
filterItems,
|
||||||
|
setFilterItems,
|
||||||
|
filters,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<GenericTable<T>
|
||||||
|
entityName={config.entityName}
|
||||||
|
sliceSelector={config.sliceSelector}
|
||||||
|
fetchAction={config.fetchAction}
|
||||||
|
updateAction={config.updateAction}
|
||||||
|
deleteAction={config.deleteAction}
|
||||||
|
deleteByIdsAction={config.deleteByIdsAction}
|
||||||
|
setRefetchAction={config.setRefetchAction}
|
||||||
|
loadColumnsFunction={config.loadColumnsFunction}
|
||||||
|
filters={filters}
|
||||||
|
filterItems={filterItems}
|
||||||
|
setFilterItems={setFilterItems}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TableComponent.displayName = `Table${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`;
|
||||||
|
|
||||||
|
return TableComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createTableComponent;
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ColorButtonKey } from '../interfaces';
|
import { ColorButtonKey } from '../interfaces';
|
||||||
import BaseButton from './BaseButton';
|
import BaseButton from './BaseButton';
|
||||||
import ImagesUploader from './Uploaders/ImagesUploader';
|
|
||||||
import FileUploader from './Uploaders/UploadService';
|
import FileUploader from './Uploaders/UploadService';
|
||||||
import { mdiReload } from '@mdi/js';
|
import { mdiReload } from '@mdi/js';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import KanbanColumn from './KanbanColumn';
|
|
||||||
import { AsyncThunk } from '@reduxjs/toolkit';
|
|
||||||
import { DndProvider } from 'react-dnd';
|
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
columns: Array<{ id: string; label: string }>;
|
|
||||||
filtersQuery: string;
|
|
||||||
entityName: string;
|
|
||||||
columnFieldName: string;
|
|
||||||
showFieldName: string;
|
|
||||||
deleteThunk: AsyncThunk<any, any, any>;
|
|
||||||
updateThunk: AsyncThunk<any, any, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const KanbanBoard = ({
|
|
||||||
columns,
|
|
||||||
entityName,
|
|
||||||
columnFieldName,
|
|
||||||
filtersQuery,
|
|
||||||
showFieldName,
|
|
||||||
deleteThunk,
|
|
||||||
updateThunk,
|
|
||||||
}: Props) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'pb-2 flex-grow min-h-[400px] flex-1 grid grid-rows-1 auto-cols-min grid-flow-col gap-x-3 overflow-y-hidden overflow-x-auto'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DndProvider backend={HTML5Backend}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<div key={column.id}>
|
|
||||||
<KanbanColumn
|
|
||||||
entityName={entityName}
|
|
||||||
columnFieldName={columnFieldName}
|
|
||||||
showFieldName={showFieldName}
|
|
||||||
column={column}
|
|
||||||
filtersQuery={filtersQuery}
|
|
||||||
deleteThunk={deleteThunk}
|
|
||||||
updateThunk={updateThunk}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</DndProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KanbanBoard;
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import moment from 'moment';
|
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
|
||||||
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
item: any;
|
|
||||||
column: { id: string; label: string };
|
|
||||||
entityName: string;
|
|
||||||
showFieldName: string;
|
|
||||||
setItemIdToDelete: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const KanbanCard = ({
|
|
||||||
item,
|
|
||||||
entityName,
|
|
||||||
showFieldName,
|
|
||||||
setItemIdToDelete,
|
|
||||||
column,
|
|
||||||
}: Props) => {
|
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [{ isDragging }, drag] = useDrag(
|
|
||||||
() => ({
|
|
||||||
type: 'box',
|
|
||||||
item: { item, column },
|
|
||||||
collect: (monitor: DragSourceMonitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect the drag ref to the DOM element
|
|
||||||
useEffect(() => {
|
|
||||||
if (cardRef.current) {
|
|
||||||
drag(cardRef.current);
|
|
||||||
}
|
|
||||||
}, [drag]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={cardRef}
|
|
||||||
className={`bg-gray-50 dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
|
||||||
>
|
|
||||||
<div className={'flex items-center justify-between'}>
|
|
||||||
<Link
|
|
||||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
|
||||||
className={'text-base font-semibold'}
|
|
||||||
>
|
|
||||||
{item[showFieldName] ?? 'No data'}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className={'flex items-center justify-between'}>
|
|
||||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
|
||||||
<ListActionsPopover
|
|
||||||
itemId={item.id}
|
|
||||||
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
|
||||||
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
|
||||||
onDelete={(id) => setItemIdToDelete(id)}
|
|
||||||
hasUpdatePermission={true}
|
|
||||||
className={'w-2 h-2 text-white'}
|
|
||||||
iconClassName={'w-5'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KanbanCard;
|
|
||||||
@ -1,218 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
|
||||||
import Axios from 'axios';
|
|
||||||
import CardBox from '../CardBox';
|
|
||||||
import CardBoxModal from '../CardBoxModal';
|
|
||||||
import { AsyncThunk } from '@reduxjs/toolkit';
|
|
||||||
import { useDrop } from 'react-dnd';
|
|
||||||
import KanbanCard from './KanbanCard';
|
|
||||||
import { logger } from '../../lib/logger';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
column: { id: string; label: string };
|
|
||||||
entityName: string;
|
|
||||||
columnFieldName: string;
|
|
||||||
showFieldName: string;
|
|
||||||
filtersQuery: any;
|
|
||||||
deleteThunk: AsyncThunk<any, any, any>;
|
|
||||||
updateThunk: AsyncThunk<any, any, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DropResult = {
|
|
||||||
sourceColumn: { id: string; label: string };
|
|
||||||
item: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const perPage = 10;
|
|
||||||
|
|
||||||
const KanbanColumn = ({
|
|
||||||
column,
|
|
||||||
entityName,
|
|
||||||
columnFieldName,
|
|
||||||
showFieldName,
|
|
||||||
filtersQuery,
|
|
||||||
deleteThunk,
|
|
||||||
updateThunk,
|
|
||||||
}: Props) => {
|
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [itemIdToDelete, setItemIdToDelete] = useState('');
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
|
||||||
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [{ dropResult }, drop] = useDrop<
|
|
||||||
{
|
|
||||||
item: any;
|
|
||||||
column: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
dropResult: DropResult;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
() => ({
|
|
||||||
accept: 'box',
|
|
||||||
drop: ({
|
|
||||||
item,
|
|
||||||
column: sourceColumn,
|
|
||||||
}: {
|
|
||||||
item: any;
|
|
||||||
column: { id: string; label: string };
|
|
||||||
}) => {
|
|
||||||
if (sourceColumn.id === column.id) return;
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
updateThunk({
|
|
||||||
id: item.id,
|
|
||||||
data: {
|
|
||||||
[columnFieldName]: column.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).then((res) => {
|
|
||||||
setData((prevState) => (prevState ? [...prevState, item] : [item]));
|
|
||||||
setCount((prevState) => prevState + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { sourceColumn, item };
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
dropResult: monitor.getDropResult(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadData = useCallback(
|
|
||||||
(page: number, filters = '') => {
|
|
||||||
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
|
|
||||||
setLoading(true);
|
|
||||||
Axios.get(`${entityName}${query}`)
|
|
||||||
.then((res) => {
|
|
||||||
setData((prevState) =>
|
|
||||||
page === 0 ? res.data.rows : [...prevState, ...res.data.rows],
|
|
||||||
);
|
|
||||||
setCount(res.data.count);
|
|
||||||
setCurrentPage(page);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logger.error(
|
|
||||||
'Failed to load data:',
|
|
||||||
err instanceof Error ? err : { error: err },
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[currentUser, column],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentUser) return;
|
|
||||||
loadData(0, filtersQuery);
|
|
||||||
}, [currentUser, loadData, filtersQuery]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData(0, filtersQuery);
|
|
||||||
}, [loadData, filtersQuery]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) {
|
|
||||||
setData((prevState) =>
|
|
||||||
prevState.filter((item) => item.id !== dropResult.item.id),
|
|
||||||
);
|
|
||||||
setCount((prevState) => prevState - 1);
|
|
||||||
}
|
|
||||||
}, [dropResult]);
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
if (listInnerRef.current) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
|
|
||||||
if (Math.floor(scrollTop + clientHeight) === scrollHeight) {
|
|
||||||
if (data.length < count && !loading) {
|
|
||||||
loadData(currentPage + 1, filtersQuery);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteConfirm = () => {
|
|
||||||
if (!itemIdToDelete) return;
|
|
||||||
|
|
||||||
dispatch(deleteThunk(itemIdToDelete))
|
|
||||||
.then((res) => {
|
|
||||||
if (res.meta.requestStatus === 'fulfilled') {
|
|
||||||
setItemIdToDelete('');
|
|
||||||
loadData(0, filtersQuery);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
logger.error(
|
|
||||||
'Delete operation failed:',
|
|
||||||
err instanceof Error ? err : { error: err },
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setItemIdToDelete('');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardBox
|
|
||||||
hasComponentLayout
|
|
||||||
className={
|
|
||||||
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={'flex items-center justify-between p-3'}>
|
|
||||||
<p className={'uppercase'}>{column.label}</p>
|
|
||||||
<p>{count}</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={(node) => {
|
|
||||||
drop(node);
|
|
||||||
listInnerRef.current = node;
|
|
||||||
}}
|
|
||||||
className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'}
|
|
||||||
onScroll={onScroll}
|
|
||||||
>
|
|
||||||
{data?.map((item) => (
|
|
||||||
<div key={item.id}>
|
|
||||||
<KanbanCard
|
|
||||||
item={item}
|
|
||||||
column={column}
|
|
||||||
showFieldName={showFieldName}
|
|
||||||
entityName={entityName}
|
|
||||||
setItemIdToDelete={setItemIdToDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!data?.length && (
|
|
||||||
<p className={'text-center py-8 bg-gray-50 dark:bg-dark-800'}>
|
|
||||||
No data
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
<CardBoxModal
|
|
||||||
title='Please confirm'
|
|
||||||
buttonColor='info'
|
|
||||||
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
|
|
||||||
isActive={!!itemIdToDelete}
|
|
||||||
onConfirm={onDeleteConfirm}
|
|
||||||
onCancel={() => setItemIdToDelete('')}
|
|
||||||
>
|
|
||||||
<p>Are you sure you want to delete this item?</p>
|
|
||||||
</CardBoxModal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default KanbanColumn;
|
|
||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Permissions Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/permissions/permissionsSlice';
|
} from '../../stores/permissions/permissionsSlice';
|
||||||
import { loadColumns } from './configurePermissionsCols';
|
import { loadColumns } from './configurePermissionsCols';
|
||||||
import type { PermissionEntity } from '../../types/entities';
|
import type { PermissionEntity } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TablePermissionsProps {
|
const TablePermissions = createTableComponent<PermissionEntity>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'permissions',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.permissions,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TablePermissions: React.FC<TablePermissionsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<PermissionEntity>
|
|
||||||
entityName='permissions'
|
|
||||||
sliceSelector={(state: RootState) => state.permissions}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TablePermissions;
|
export default TablePermissions;
|
||||||
|
|||||||
@ -1,68 +1,14 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PERMISSIONS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'permissions',
|
||||||
export const loadColumns = async (
|
columns: PERMISSIONS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_PERMISSIONS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/permissions/permissions-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/permissions/permissions-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Presigned URL Requests Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,34 +8,16 @@ import {
|
|||||||
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
|
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
|
||||||
import { loadColumns } from './configurePresigned_url_requestsCols';
|
import { loadColumns } from './configurePresigned_url_requestsCols';
|
||||||
import type { PresignedUrlRequest } from '../../types/entities';
|
import type { PresignedUrlRequest } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TablePresigned_url_requestsProps {
|
const TablePresigned_url_requests = createTableComponent<PresignedUrlRequest>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'presigned_url_requests',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.presigned_url_requests,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TablePresigned_url_requests: React.FC<
|
setRefetchAction: setRefetch,
|
||||||
TablePresigned_url_requestsProps
|
loadColumnsFunction: loadColumns,
|
||||||
> = ({ filterItems, setFilterItems, filters }) => {
|
});
|
||||||
return (
|
|
||||||
<GenericTable<PresignedUrlRequest>
|
|
||||||
entityName='presigned_url_requests'
|
|
||||||
sliceSelector={(state: RootState) => state.presigned_url_requests}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TablePresigned_url_requests;
|
export default TablePresigned_url_requests;
|
||||||
|
|||||||
@ -1,190 +1,54 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PRESIGNED_URL_REQUESTS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'user',
|
||||||
|
headerName: 'User',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'users',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'purpose', headerName: 'Purpose', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'asset_type',
|
||||||
|
headerName: 'Assettype',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'requested_key',
|
||||||
|
headerName: 'Requestedkey',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'mime_type', headerName: 'MIMEtype', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'requested_size_mb',
|
||||||
|
headerName: 'Requestedsize(MB)',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'expires_at',
|
||||||
|
headerName: 'Expiresat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'status', headerName: 'Status', type: 'text', editable: true },
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'presigned_url_requests',
|
||||||
export const loadColumns = async (
|
columns: PRESIGNED_URL_REQUESTS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(
|
|
||||||
user,
|
|
||||||
'UPDATE_PRESIGNED_URL_REQUESTS',
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'user',
|
|
||||||
headerName: 'User',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('users'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'purpose',
|
|
||||||
headerName: 'Purpose',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'asset_type',
|
|
||||||
headerName: 'Assettype',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'requested_key',
|
|
||||||
headerName: 'Requestedkey',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'mime_type',
|
|
||||||
headerName: 'MIMEtype',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'requested_size_mb',
|
|
||||||
headerName: 'Requestedsize(MB)',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'expires_at',
|
|
||||||
headerName: 'Expiresat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.expires_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
headerName: 'Status',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/presigned_url_requests/presigned_url_requests-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/presigned_url_requests/presigned_url_requests-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Project Audio Tracks Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
|
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
|
||||||
import { loadColumns } from './configureProject_audio_tracksCols';
|
import { loadColumns } from './configureProject_audio_tracksCols';
|
||||||
import type { ProjectAudioTrack } from '../../types/entities';
|
import type { ProjectAudioTrack } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableProject_audio_tracksProps {
|
const TableProject_audio_tracks = createTableComponent<ProjectAudioTrack>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'project_audio_tracks',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.project_audio_tracks,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableProject_audio_tracks: React.FC<TableProject_audio_tracksProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<ProjectAudioTrack>
|
|
||||||
entityName='project_audio_tracks'
|
|
||||||
sliceSelector={(state: RootState) => state.project_audio_tracks}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableProject_audio_tracks;
|
export default TableProject_audio_tracks;
|
||||||
|
|||||||
@ -1,196 +1,49 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PROJECT_AUDIO_TRACKS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'environment',
|
||||||
|
headerName: 'Environment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'source_key',
|
||||||
|
headerName: 'Sourcekey',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||||
|
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
|
||||||
|
{ field: 'url', headerName: 'URL', type: 'text', editable: true },
|
||||||
|
{ field: 'loop', headerName: 'Loop', type: 'boolean', editable: true },
|
||||||
|
{ field: 'volume', headerName: 'Volume', type: 'number', editable: true },
|
||||||
|
{
|
||||||
|
field: 'sort_order',
|
||||||
|
headerName: 'Sortorder',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'is_enabled',
|
||||||
|
headerName: 'Isenabled',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'project_audio_tracks',
|
||||||
export const loadColumns = async (
|
columns: PROJECT_AUDIO_TRACKS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(
|
|
||||||
user,
|
|
||||||
'UPDATE_PROJECT_AUDIO_TRACKS',
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'environment',
|
|
||||||
headerName: 'Environment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'source_key',
|
|
||||||
headerName: 'Sourcekey',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'slug',
|
|
||||||
headerName: 'Slug',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'url',
|
|
||||||
headerName: 'URL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'loop',
|
|
||||||
headerName: 'Loop',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'volume',
|
|
||||||
headerName: 'Volume',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'sort_order',
|
|
||||||
headerName: 'Sortorder',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_enabled',
|
|
||||||
headerName: 'Isenabled',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/project_audio_tracks/project_audio_tracks-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/project_audio_tracks/project_audio_tracks-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Project Memberships Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/project_memberships/project_membershipsSlice';
|
} from '../../stores/project_memberships/project_membershipsSlice';
|
||||||
import { loadColumns } from './configureProject_membershipsCols';
|
import { loadColumns } from './configureProject_membershipsCols';
|
||||||
import type { ProjectMembership } from '../../types/entities';
|
import type { ProjectMembership } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableProject_membershipsProps {
|
const TableProject_memberships = createTableComponent<ProjectMembership>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'project_memberships',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.project_memberships,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableProject_memberships: React.FC<TableProject_membershipsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<ProjectMembership>
|
|
||||||
entityName='project_memberships'
|
|
||||||
sliceSelector={(state: RootState) => state.project_memberships}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableProject_memberships;
|
export default TableProject_memberships;
|
||||||
|
|||||||
@ -1,154 +1,51 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PROJECT_MEMBERSHIPS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'user',
|
||||||
|
headerName: 'User',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'users',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'access_level',
|
||||||
|
headerName: 'Accesslevel',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'is_active',
|
||||||
|
headerName: 'Isactive',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'invited_at',
|
||||||
|
headerName: 'Invitedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'accepted_at',
|
||||||
|
headerName: 'Acceptedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'project_memberships',
|
||||||
export const loadColumns = async (
|
columns: PROJECT_MEMBERSHIPS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_PROJECT_MEMBERSHIPS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'user',
|
|
||||||
headerName: 'User',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('users'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'access_level',
|
|
||||||
headerName: 'Accesslevel',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_active',
|
|
||||||
headerName: 'Isactive',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'invited_at',
|
|
||||||
headerName: 'Invitedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.invited_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'accepted_at',
|
|
||||||
headerName: 'Acceptedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.accepted_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/project_memberships/project_memberships-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/project_memberships/project_memberships-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Projects Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/projects/projectsSlice';
|
} from '../../stores/projects/projectsSlice';
|
||||||
import { loadColumns } from './configureProjectsCols';
|
import { loadColumns } from './configureProjectsCols';
|
||||||
import type { Project } from '../../types/entities';
|
import type { Project } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableProjectsProps {
|
const TableProjects = createTableComponent<Project>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'projects',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.projects,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableProjects: React.FC<TableProjectsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<Project>
|
|
||||||
entityName='projects'
|
|
||||||
sliceSelector={(state: RootState) => state.projects}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableProjects;
|
export default TableProjects;
|
||||||
|
|||||||
@ -1,193 +1,64 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PROJECTS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||||
|
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'description',
|
||||||
|
headerName: 'Description',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'logo_url', headerName: 'LogoURL', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'favicon_url',
|
||||||
|
headerName: 'FaviconURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'og_image_url',
|
||||||
|
headerName: 'OGImageURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'theme_config_json',
|
||||||
|
headerName: 'ThemeconfigJSON',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'custom_css_json',
|
||||||
|
headerName: 'CustomCSSJSON',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'cdn_base_url',
|
||||||
|
headerName: 'CDNbaseURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'is_deleted',
|
||||||
|
headerName: 'Isdeleted',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'deleted_at_time',
|
||||||
|
headerName: 'Deletedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'projects',
|
||||||
export const loadColumns = async (
|
columns: PROJECTS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_PROJECTS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'slug',
|
|
||||||
headerName: 'Slug',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'description',
|
|
||||||
headerName: 'Description',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'logo_url',
|
|
||||||
headerName: 'LogoURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'favicon_url',
|
|
||||||
headerName: 'FaviconURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'og_image_url',
|
|
||||||
headerName: 'OGImageURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'theme_config_json',
|
|
||||||
headerName: 'ThemeconfigJSON',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'custom_css_json',
|
|
||||||
headerName: 'CustomCSSJSON',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'cdn_base_url',
|
|
||||||
headerName: 'CDNbaseURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_deleted',
|
|
||||||
headerName: 'Isdeleted',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'deleted_at_time',
|
|
||||||
headerName: 'Deletedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.deleted_at_time),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/projects/projects-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/projects/projects-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Publish Events Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/publish_events/publish_eventsSlice';
|
} from '../../stores/publish_events/publish_eventsSlice';
|
||||||
import { loadColumns } from './configurePublish_eventsCols';
|
import { loadColumns } from './configurePublish_eventsCols';
|
||||||
import type { PublishEvent } from '../../types/entities';
|
import type { PublishEvent } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TablePublish_eventsProps {
|
const TablePublish_events = createTableComponent<PublishEvent>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'publish_events',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.publish_events,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TablePublish_events: React.FC<TablePublish_eventsProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<PublishEvent>
|
|
||||||
entityName='publish_events'
|
|
||||||
sliceSelector={(state: RootState) => state.publish_events}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TablePublish_events;
|
export default TablePublish_events;
|
||||||
|
|||||||
@ -1,242 +1,90 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PUBLISH_EVENTS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'user',
|
||||||
|
headerName: 'User',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'users',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'from_environment',
|
||||||
|
headerName: 'Fromenvironment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'to_environment',
|
||||||
|
headerName: 'Toenvironment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'started_at',
|
||||||
|
headerName: 'Startedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'finished_at',
|
||||||
|
headerName: 'Finishedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'title',
|
||||||
|
headerName: 'Title',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
minWidth: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'description',
|
||||||
|
headerName: 'Description',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
minWidth: 220,
|
||||||
|
},
|
||||||
|
{ field: 'status', headerName: 'Status', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'error_message',
|
||||||
|
headerName: 'Errormessage',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'pages_copied',
|
||||||
|
headerName: 'Pagescopied',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'transitions_copied',
|
||||||
|
headerName: 'Transitionscopied',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'audios_copied',
|
||||||
|
headerName: 'Audioscopied',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'publish_events',
|
||||||
export const loadColumns = async (
|
columns: PUBLISH_EVENTS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_PUBLISH_EVENTS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'user',
|
|
||||||
headerName: 'User',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('users'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'from_environment',
|
|
||||||
headerName: 'Fromenvironment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'to_environment',
|
|
||||||
headerName: 'Toenvironment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'started_at',
|
|
||||||
headerName: 'Startedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.started_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'finished_at',
|
|
||||||
headerName: 'Finishedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.finished_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'title',
|
|
||||||
headerName: 'Title',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 160,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'description',
|
|
||||||
headerName: 'Description',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 220,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'status',
|
|
||||||
headerName: 'Status',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'error_message',
|
|
||||||
headerName: 'Errormessage',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'pages_copied',
|
|
||||||
headerName: 'Pagescopied',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'transitions_copied',
|
|
||||||
headerName: 'Transitionscopied',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'audios_copied',
|
|
||||||
headerName: 'Audioscopied',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/publish_events/publish_events-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/publish_events/publish_events-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* PWA Caches Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/pwa_caches/pwa_cachesSlice';
|
} from '../../stores/pwa_caches/pwa_cachesSlice';
|
||||||
import { loadColumns } from './configurePwa_cachesCols';
|
import { loadColumns } from './configurePwa_cachesCols';
|
||||||
import type { PwaCache } from '../../types/entities';
|
import type { PwaCache } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TablePwa_cachesProps {
|
const TablePwa_caches = createTableComponent<PwaCache>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'pwa_caches',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.pwa_caches,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TablePwa_caches: React.FC<TablePwa_cachesProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<PwaCache>
|
|
||||||
entityName='pwa_caches'
|
|
||||||
sliceSelector={(state: RootState) => state.pwa_caches}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TablePwa_caches;
|
export default TablePwa_caches;
|
||||||
|
|||||||
@ -1,154 +1,56 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const PWA_CACHES_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'environment',
|
||||||
|
headerName: 'Environment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'cache_version',
|
||||||
|
headerName: 'Cacheversion',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'manifest_json',
|
||||||
|
headerName: 'ManifestJSON',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'asset_list_json',
|
||||||
|
headerName: 'AssetlistJSON',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'generated_at',
|
||||||
|
headerName: 'Generatedat',
|
||||||
|
type: 'datetime',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'is_active',
|
||||||
|
headerName: 'Isactive',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'pwa_caches',
|
||||||
export const loadColumns = async (
|
columns: PWA_CACHES_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_PWA_CACHES');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'environment',
|
|
||||||
headerName: 'Environment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'cache_version',
|
|
||||||
headerName: 'Cacheversion',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'manifest_json',
|
|
||||||
headerName: 'ManifestJSON',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'asset_list_json',
|
|
||||||
headerName: 'AssetlistJSON',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'generated_at',
|
|
||||||
headerName: 'Generatedat',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'dateTime' as const,
|
|
||||||
valueGetter: (_value, row) => new Date(row.generated_at),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'is_active',
|
|
||||||
headerName: 'Isactive',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/pwa_caches/pwa_caches-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/pwa_caches/pwa_caches-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Roles Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/roles/rolesSlice';
|
} from '../../stores/roles/rolesSlice';
|
||||||
import { loadColumns } from './configureRolesCols';
|
import { loadColumns } from './configureRolesCols';
|
||||||
import type { Role } from '../../types/entities';
|
import type { Role } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableRolesProps {
|
const TableRoles = createTableComponent<Role>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'roles',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.roles,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableRoles: React.FC<TableRolesProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<Role>
|
|
||||||
entityName='roles'
|
|
||||||
sliceSelector={(state: RootState) => state.roles}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableRoles;
|
export default TableRoles;
|
||||||
|
|||||||
@ -1,89 +1,30 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
import DataGridMultiSelect from '../DataGridMultiSelect';
|
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const ROLES_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'name',
|
||||||
|
headerName: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'permissions',
|
||||||
|
headerName: 'Permissions',
|
||||||
|
type: 'relationMany',
|
||||||
|
entityRef: 'permissions',
|
||||||
|
editable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
headerName: '',
|
||||||
|
type: 'actions',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'roles',
|
||||||
export const loadColumns = async (
|
columns: ROLES_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ROLES');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'permissions',
|
|
||||||
headerName: 'Permissions',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: false,
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
valueFormatter: ({ value }) =>
|
|
||||||
dataFormatter.permissionsManyListFormatter(value).join(', '),
|
|
||||||
renderEditCell: (params) => (
|
|
||||||
<DataGridMultiSelect {...params} entityName={'permissions'} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/roles/roles-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/roles/roles-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
import { mdiEye, mdiTrashCan } from '@mdi/js';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useSampleClients } from '../hooks/sampleData';
|
|
||||||
import { Client } from '../interfaces';
|
|
||||||
import BaseButton from './BaseButton';
|
|
||||||
import BaseButtons from './BaseButtons';
|
|
||||||
import CardBoxModal from './CardBoxModal';
|
|
||||||
import UserAvatar from './UserAvatar';
|
|
||||||
|
|
||||||
const TableSampleClients = () => {
|
|
||||||
const { clients } = useSampleClients();
|
|
||||||
|
|
||||||
const perPage = 5;
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
|
||||||
|
|
||||||
const clientsPaginated = clients.slice(
|
|
||||||
perPage * currentPage,
|
|
||||||
perPage * (currentPage + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
const numPages = clients.length / perPage;
|
|
||||||
|
|
||||||
const pagesList = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < numPages; i++) {
|
|
||||||
pagesList.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false);
|
|
||||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false);
|
|
||||||
|
|
||||||
const handleModalAction = () => {
|
|
||||||
setIsModalInfoActive(false);
|
|
||||||
setIsModalTrashActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardBoxModal
|
|
||||||
title='Sample modal'
|
|
||||||
buttonColor='info'
|
|
||||||
buttonLabel='Done'
|
|
||||||
isActive={isModalInfoActive}
|
|
||||||
onConfirm={handleModalAction}
|
|
||||||
onCancel={handleModalAction}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Lorem ipsum dolor sit amet <b>adipiscing elit</b>
|
|
||||||
</p>
|
|
||||||
<p>This is sample modal</p>
|
|
||||||
</CardBoxModal>
|
|
||||||
|
|
||||||
<CardBoxModal
|
|
||||||
title='Please confirm'
|
|
||||||
buttonColor='danger'
|
|
||||||
buttonLabel='Confirm'
|
|
||||||
isActive={isModalTrashActive}
|
|
||||||
onConfirm={handleModalAction}
|
|
||||||
onCancel={handleModalAction}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Lorem ipsum dolor sit amet <b>adipiscing elit</b>
|
|
||||||
</p>
|
|
||||||
<p>This is sample modal</p>
|
|
||||||
</CardBoxModal>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th />
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Company</th>
|
|
||||||
<th>City</th>
|
|
||||||
<th>Progress</th>
|
|
||||||
<th>Created</th>
|
|
||||||
<th />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clientsPaginated.map((client: Client) => (
|
|
||||||
<tr key={client.id}>
|
|
||||||
<td className='border-b-0 lg:w-6 before:hidden'>
|
|
||||||
<UserAvatar
|
|
||||||
username={client.name}
|
|
||||||
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td data-label='Name'>{client.name}</td>
|
|
||||||
<td data-label='Company'>{client.company}</td>
|
|
||||||
<td data-label='City'>{client.city}</td>
|
|
||||||
<td data-label='Progress' className='lg:w-32'>
|
|
||||||
<progress
|
|
||||||
className='flex w-2/5 self-center lg:w-full'
|
|
||||||
max='100'
|
|
||||||
value={client.progress}
|
|
||||||
>
|
|
||||||
{client.progress}
|
|
||||||
</progress>
|
|
||||||
</td>
|
|
||||||
<td data-label='Created' className='lg:w-1 whitespace-nowrap'>
|
|
||||||
<small className='text-gray-500 dark:text-slate-400'>
|
|
||||||
{client.created}
|
|
||||||
</small>
|
|
||||||
</td>
|
|
||||||
<td className='before:hidden lg:w-1 whitespace-nowrap'>
|
|
||||||
<BaseButtons type='justify-start lg:justify-end' noWrap>
|
|
||||||
<BaseButton
|
|
||||||
color='info'
|
|
||||||
icon={mdiEye}
|
|
||||||
onClick={() => setIsModalInfoActive(true)}
|
|
||||||
small
|
|
||||||
/>
|
|
||||||
<BaseButton
|
|
||||||
color='danger'
|
|
||||||
icon={mdiTrashCan}
|
|
||||||
onClick={() => setIsModalTrashActive(true)}
|
|
||||||
small
|
|
||||||
/>
|
|
||||||
</BaseButtons>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div className='p-3 lg:px-6 border-t border-gray-100 dark:border-slate-800'>
|
|
||||||
<div className='flex flex-col md:flex-row items-center justify-between py-3 md:py-0'>
|
|
||||||
<BaseButtons>
|
|
||||||
{pagesList.map((page) => (
|
|
||||||
<BaseButton
|
|
||||||
key={page}
|
|
||||||
active={page === currentPage}
|
|
||||||
label={page + 1}
|
|
||||||
color={page === currentPage ? 'lightDark' : 'whiteDark'}
|
|
||||||
small
|
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BaseButtons>
|
|
||||||
<small className='mt-6 md:mt-0'>
|
|
||||||
Page {currentPage + 1} of {numPages}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableSampleClients;
|
|
||||||
@ -19,6 +19,13 @@ import { getPageTitle } from '../config';
|
|||||||
import { hasPermission } from '../helpers/userPermissions';
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
||||||
|
import {
|
||||||
|
toRoutePath,
|
||||||
|
compareRoutes,
|
||||||
|
getProjectId,
|
||||||
|
getRows,
|
||||||
|
} from '../lib/tourFlowHelpers';
|
||||||
|
|
||||||
type TourPage = {
|
type TourPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -54,79 +61,6 @@ type ListEntry = {
|
|||||||
parentPageId: string;
|
parentPageId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRows = (response: any) =>
|
|
||||||
Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
|
||||||
|
|
||||||
const sanitizeSlug = (value: string) =>
|
|
||||||
value
|
|
||||||
.toLowerCase()
|
|
||||||
.trim()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
|
|
||||||
const buildUniqueSlug = (baseValue: string, usedSlugs: Set<string>) => {
|
|
||||||
const baseSlug = sanitizeSlug(baseValue) || 'page';
|
|
||||||
if (!usedSlugs.has(baseSlug)) return baseSlug;
|
|
||||||
|
|
||||||
let suffix = 2;
|
|
||||||
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseSlug}-${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
||||||
|
|
||||||
const toRoutePath = (value?: string) => {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
if (!raw || raw === '/') return '/';
|
|
||||||
|
|
||||||
const normalized = raw
|
|
||||||
.replace(/^[^/]+:\/\//, '')
|
|
||||||
.replace(/^\/*/, '')
|
|
||||||
.replace(/\/{2,}/g, '/');
|
|
||||||
|
|
||||||
const withSlash = `/${normalized}`;
|
|
||||||
return withSlash.length > 1 ? withSlash.replace(/\/$/, '') : withSlash;
|
|
||||||
};
|
|
||||||
|
|
||||||
const routeParts = (value?: string) =>
|
|
||||||
toRoutePath(value).split('/').filter(Boolean);
|
|
||||||
|
|
||||||
const compareRoutes = (a?: string, b?: string) => {
|
|
||||||
const aPath = toRoutePath(a);
|
|
||||||
const bPath = toRoutePath(b);
|
|
||||||
if (aPath === bPath) return 0;
|
|
||||||
|
|
||||||
const aParts = routeParts(aPath);
|
|
||||||
const bParts = routeParts(bPath);
|
|
||||||
const maxLen = Math.max(aParts.length, bParts.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < maxLen; index += 1) {
|
|
||||||
const aPart = aParts[index];
|
|
||||||
const bPart = bParts[index];
|
|
||||||
|
|
||||||
if (aPart === undefined) return -1;
|
|
||||||
if (bPart === undefined) return 1;
|
|
||||||
|
|
||||||
const compare = aPart.localeCompare(bPart);
|
|
||||||
if (compare !== 0) return compare;
|
|
||||||
}
|
|
||||||
|
|
||||||
return aParts.length - bParts.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProjectId = (item: {
|
|
||||||
projectId?: string;
|
|
||||||
project?: { id?: string } | string;
|
|
||||||
}) => {
|
|
||||||
if (item.projectId) return item.projectId;
|
|
||||||
if (typeof item.project === 'string') return item.project;
|
|
||||||
if (item.project?.id) return item.project.id;
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const TourFlowManager = () => {
|
const TourFlowManager = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Tour Pages Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/tour_pages/tour_pagesSlice';
|
} from '../../stores/tour_pages/tour_pagesSlice';
|
||||||
import { loadColumns } from './configureTour_pagesCols';
|
import { loadColumns } from './configureTour_pagesCols';
|
||||||
import type { TourPage } from '../../types/entities';
|
import type { TourPage } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableTour_pagesProps {
|
const TableTour_pages = createTableComponent<TourPage>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'tour_pages',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.tour_pages,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableTour_pages: React.FC<TableTour_pagesProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<TourPage>
|
|
||||||
entityName='tour_pages'
|
|
||||||
sliceSelector={(state: RootState) => state.tour_pages}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableTour_pages;
|
export default TableTour_pages;
|
||||||
|
|||||||
@ -1,215 +1,76 @@
|
|||||||
import React from 'react';
|
import {
|
||||||
import axios from 'axios';
|
createColumnLoader,
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
ColumnMetadata,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const TOUR_PAGES_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'project',
|
||||||
|
headerName: 'Project',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'projects',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'environment',
|
||||||
|
headerName: 'Environment',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'source_key',
|
||||||
|
headerName: 'Sourcekey',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||||
|
{ field: 'slug', headerName: 'Slug', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'sort_order',
|
||||||
|
headerName: 'Sortorder',
|
||||||
|
type: 'number',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'background_image_url',
|
||||||
|
headerName: 'BackgroundimageURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'background_video_url',
|
||||||
|
headerName: 'BackgroundvideoURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'background_audio_url',
|
||||||
|
headerName: 'BackgroundaudioURL',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'background_loop',
|
||||||
|
headerName: 'Backgroundloop',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'requires_auth',
|
||||||
|
headerName: 'Requiresauth',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'ui_schema_json',
|
||||||
|
headerName: 'UIschemaJSON',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'tour_pages',
|
||||||
export const loadColumns = async (
|
columns: TOUR_PAGES_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_TOUR_PAGES');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'project',
|
|
||||||
headerName: 'Project',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('projects'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'environment',
|
|
||||||
headerName: 'Environment',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'source_key',
|
|
||||||
headerName: 'Sourcekey',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'name',
|
|
||||||
headerName: 'Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'slug',
|
|
||||||
headerName: 'Slug',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'sort_order',
|
|
||||||
headerName: 'Sortorder',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'background_image_url',
|
|
||||||
headerName: 'BackgroundimageURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'background_video_url',
|
|
||||||
headerName: 'BackgroundvideoURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'background_audio_url',
|
|
||||||
headerName: 'BackgroundaudioURL',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'background_loop',
|
|
||||||
headerName: 'Backgroundloop',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'requires_auth',
|
|
||||||
headerName: 'Requiresauth',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'ui_schema_json',
|
|
||||||
headerName: 'UIschemaJSON',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/tour_pages/tour_pages-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/tour_pages/tour_pages-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,133 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import FileUploader from 'components/FormItems/uploaders/UploadService';
|
|
||||||
import Errors from '../../../components/FormItems/error/errors';
|
|
||||||
|
|
||||||
const FilesUploader = (props) => {
|
|
||||||
const { value, onChange, schema, path, max, readonly } = props;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const inputElement = useRef(null);
|
|
||||||
|
|
||||||
const valuesArr = () => {
|
|
||||||
if (!value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.isArray(value) ? value : [value];
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileList = () => {
|
|
||||||
return valuesArr().map((item) => ({
|
|
||||||
uid: item.id || undefined,
|
|
||||||
name: item.name,
|
|
||||||
status: 'done',
|
|
||||||
url: item.publicUrl,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (id) => {
|
|
||||||
onChange(valuesArr().filter((item) => item.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = async (event) => {
|
|
||||||
try {
|
|
||||||
const files = event.target.files;
|
|
||||||
|
|
||||||
if (!files || !files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = files[0];
|
|
||||||
|
|
||||||
FileUploader.validate(file, schema);
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
file = await FileUploader.upload(path, file, schema);
|
|
||||||
|
|
||||||
inputElement.current.value = '';
|
|
||||||
setLoading(false);
|
|
||||||
onChange([...valuesArr(), file]);
|
|
||||||
} catch (error) {
|
|
||||||
inputElement.current.value = '';
|
|
||||||
console.log('error', error);
|
|
||||||
setLoading(false);
|
|
||||||
Errors.showMessage(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formats = () => {
|
|
||||||
if (schema && schema.formats) {
|
|
||||||
return schema.formats.map((format) => `.${format}`).join(',');
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadButton = (
|
|
||||||
<label
|
|
||||||
className='btn btn-outline-secondary px-4 mb-2'
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{'Upload a file'}
|
|
||||||
<input
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
disabled={loading || readonly}
|
|
||||||
accept={formats()}
|
|
||||||
type='file'
|
|
||||||
onChange={handleChange}
|
|
||||||
ref={inputElement}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{readonly || (max && fileList().length >= max) ? null : uploadButton}
|
|
||||||
|
|
||||||
{valuesArr() && valuesArr().length ? (
|
|
||||||
<div>
|
|
||||||
{valuesArr().map((item) => {
|
|
||||||
return (
|
|
||||||
<div key={item.id}>
|
|
||||||
<i className='la la-link text-muted mr-2'></i>
|
|
||||||
<a
|
|
||||||
href={item.publicUrl}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
download
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{!readonly && (
|
|
||||||
<button
|
|
||||||
className='btn btn-link'
|
|
||||||
type='button'
|
|
||||||
onClick={() => handleRemove(item.id)}
|
|
||||||
>
|
|
||||||
<i className='la la-times'></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
FilesUploader.propTypes = {
|
|
||||||
readonly: PropTypes.bool,
|
|
||||||
path: PropTypes.string,
|
|
||||||
max: PropTypes.number,
|
|
||||||
schema: PropTypes.shape({
|
|
||||||
image: PropTypes.bool,
|
|
||||||
size: PropTypes.number,
|
|
||||||
formats: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
}),
|
|
||||||
value: PropTypes.any,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FilesUploader;
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
|
||||||
import Grid from '@mui/material/Grid';
|
|
||||||
import Box from '@mui/material/Box';
|
|
||||||
import Dialog from '@mui/material/Dialog';
|
|
||||||
import FileUploader from 'components/FormItems/uploaders/UploadService';
|
|
||||||
import Errors from '../../../components/FormItems/error/errors';
|
|
||||||
import { makeStyles } from '@mui/styles';
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
actionButtonsWrapper: {
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
previewContent: {
|
|
||||||
padding: '0px !important',
|
|
||||||
},
|
|
||||||
imageItem: {
|
|
||||||
'&.MuiGrid-root': {
|
|
||||||
margin: 10,
|
|
||||||
boxShadow: '2px 2px 8px 0 rgb(0 0 0 / 40%)',
|
|
||||||
borderRadius: 10,
|
|
||||||
},
|
|
||||||
height: '100px',
|
|
||||||
},
|
|
||||||
actionButtons: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 5,
|
|
||||||
right: 4,
|
|
||||||
},
|
|
||||||
previewContainer: {
|
|
||||||
'& button': {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
'& svg': {
|
|
||||||
height: 50,
|
|
||||||
width: 50,
|
|
||||||
fill: '#FFF',
|
|
||||||
stroke: '#909090',
|
|
||||||
strokeWidth: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
padding: '0px !important',
|
|
||||||
minWidth: '45px !important',
|
|
||||||
'& svg': {
|
|
||||||
height: 36,
|
|
||||||
width: 36,
|
|
||||||
fill: '#FFF',
|
|
||||||
stroke: '#909090',
|
|
||||||
strokeWidth: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ImagesUploader = (props) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
const { value, onChange, schema, path, max, readonly, name } = props;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
const [imageMeta, setImageMeta] = useState({
|
|
||||||
imageSrc: null,
|
|
||||||
imageAlt: null,
|
|
||||||
});
|
|
||||||
const inputElement = useRef(null);
|
|
||||||
|
|
||||||
const valuesArr = () => {
|
|
||||||
if (!value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Array.isArray(value) ? value : [value];
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileList = () => {
|
|
||||||
return valuesArr().map((item) => ({
|
|
||||||
uid: item.id || undefined,
|
|
||||||
name: item.name,
|
|
||||||
status: 'done',
|
|
||||||
url: item.publicUrl,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (id) => {
|
|
||||||
onChange(valuesArr().filter((item) => item.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = async (event) => {
|
|
||||||
try {
|
|
||||||
const files = event.target.files;
|
|
||||||
|
|
||||||
if (!files || !files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = files[0];
|
|
||||||
|
|
||||||
FileUploader.validate(file, schema);
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
file = await FileUploader.upload(path, file, schema);
|
|
||||||
|
|
||||||
inputElement.current.value = '';
|
|
||||||
setLoading(false);
|
|
||||||
onChange([...valuesArr(), file]);
|
|
||||||
} catch (error) {
|
|
||||||
inputElement.current.value = '';
|
|
||||||
console.log('error', error);
|
|
||||||
setLoading(false);
|
|
||||||
Errors.showMessage(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doPreviewImage = (image) => {
|
|
||||||
setImageMeta({
|
|
||||||
imageSrc: image.publicUrl,
|
|
||||||
imageAlt: image.name,
|
|
||||||
});
|
|
||||||
setShowPreview(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doCloseImageModal = () => {
|
|
||||||
setImageMeta({
|
|
||||||
imageSrc: null,
|
|
||||||
imageAlt: null,
|
|
||||||
});
|
|
||||||
setShowPreview(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadButton = (
|
|
||||||
<Box>
|
|
||||||
<label htmlFor={'button-file-' + name} style={{ cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
id={'button-file-' + name}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
disabled={loading || readonly}
|
|
||||||
accept='image/*'
|
|
||||||
type='file'
|
|
||||||
onChange={handleChange}
|
|
||||||
ref={inputElement}
|
|
||||||
/>
|
|
||||||
<Button variant='contained' component='span'>
|
|
||||||
Upload an Image
|
|
||||||
</Button>{' '}
|
|
||||||
</label>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{readonly || (max && fileList().length >= max) ? null : uploadButton}
|
|
||||||
|
|
||||||
{valuesArr() && valuesArr().length ? (
|
|
||||||
<Grid container>
|
|
||||||
{valuesArr().map((item) => {
|
|
||||||
return (
|
|
||||||
<Grid item className={classes.imageItem} key={item.id}>
|
|
||||||
<img
|
|
||||||
alt={item.name}
|
|
||||||
src={item.publicUrl}
|
|
||||||
style={{
|
|
||||||
width: '100px',
|
|
||||||
height: '100px',
|
|
||||||
objectFit: 'cover',
|
|
||||||
borderRadius: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={classes.actionButtonsWrapper}>
|
|
||||||
<div className={classes.actionButtons}>
|
|
||||||
<Button
|
|
||||||
classes={{ root: classes.button }}
|
|
||||||
variant='text'
|
|
||||||
onClick={() => doPreviewImage(item)}
|
|
||||||
>
|
|
||||||
<SearchIcon />
|
|
||||||
</Button>
|
|
||||||
{!readonly && (
|
|
||||||
<Button
|
|
||||||
classes={{ root: classes.button }}
|
|
||||||
variant='text'
|
|
||||||
onClick={() => handleRemove(item.id)}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
) : null}
|
|
||||||
<Dialog
|
|
||||||
open={showPreview}
|
|
||||||
onClose={doCloseImageModal}
|
|
||||||
classes={{ root: classes.previewContainer }}
|
|
||||||
>
|
|
||||||
<Button variant='text' onClick={() => doCloseImageModal()}>
|
|
||||||
<CloseIcon />
|
|
||||||
</Button>
|
|
||||||
<img src={imageMeta.imageSrc} alt={imageMeta.imageAlt} />
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ImagesUploader.propTypes = {
|
|
||||||
readonly: PropTypes.bool,
|
|
||||||
path: PropTypes.string,
|
|
||||||
max: PropTypes.number,
|
|
||||||
schema: PropTypes.shape({
|
|
||||||
image: PropTypes.bool,
|
|
||||||
size: PropTypes.number,
|
|
||||||
formats: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
}),
|
|
||||||
value: PropTypes.any,
|
|
||||||
onChange: PropTypes.func,
|
|
||||||
name: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImagesUploader;
|
|
||||||
@ -199,11 +199,7 @@ export default class FileUploader {
|
|||||||
|
|
||||||
const privateUrl = `${path}/${filename}`;
|
const privateUrl = `${path}/${filename}`;
|
||||||
|
|
||||||
console.log(
|
// Debug logging removed for production builds
|
||||||
'process.env.NODE_ENV in uploadToServer function',
|
|
||||||
process.env.NODE_ENV,
|
|
||||||
);
|
|
||||||
console.log('baseURLApi in uploadToServer function', baseURLApi);
|
|
||||||
|
|
||||||
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
/**
|
import { createTableComponent } from '../Factory/createTableComponent';
|
||||||
* Users Table Component
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import GenericTable from '../Generic/GenericTable';
|
|
||||||
import {
|
import {
|
||||||
fetch,
|
fetch,
|
||||||
update,
|
update,
|
||||||
@ -13,36 +8,16 @@ import {
|
|||||||
} from '../../stores/users/usersSlice';
|
} from '../../stores/users/usersSlice';
|
||||||
import { loadColumns } from './configureUsersCols';
|
import { loadColumns } from './configureUsersCols';
|
||||||
import type { User } from '../../types/entities';
|
import type { User } from '../../types/entities';
|
||||||
import type { RootState } from '../../stores/store';
|
|
||||||
import type { Filter, FilterItem } from '../../types/filters';
|
|
||||||
|
|
||||||
interface TableUsersProps {
|
const TableUsers = createTableComponent<User>({
|
||||||
filterItems: FilterItem[];
|
entityName: 'users',
|
||||||
setFilterItems: (items: FilterItem[]) => void;
|
sliceSelector: (state) => state.users,
|
||||||
filters: Filter[];
|
fetchAction: fetch,
|
||||||
showGrid?: boolean;
|
updateAction: update,
|
||||||
}
|
deleteAction: deleteItem,
|
||||||
|
deleteByIdsAction: deleteItemsByIds,
|
||||||
const TableUsers: React.FC<TableUsersProps> = ({
|
setRefetchAction: setRefetch,
|
||||||
filterItems,
|
loadColumnsFunction: loadColumns,
|
||||||
setFilterItems,
|
});
|
||||||
filters,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<GenericTable<User>
|
|
||||||
entityName='users'
|
|
||||||
sliceSelector={(state: RootState) => state.users}
|
|
||||||
fetchAction={fetch}
|
|
||||||
updateAction={update}
|
|
||||||
deleteAction={deleteItem}
|
|
||||||
deleteByIdsAction={deleteItemsByIds}
|
|
||||||
setRefetchAction={setRefetch}
|
|
||||||
loadColumnsFunction={loadColumns}
|
|
||||||
filters={filters}
|
|
||||||
filterItems={filterItems}
|
|
||||||
setFilterItems={setFilterItems}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableUsers;
|
export default TableUsers;
|
||||||
|
|||||||
@ -1,181 +1,63 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import axios from 'axios';
|
import { GridRenderCellParams } from '@mui/x-data-grid';
|
||||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
|
||||||
import ImageField from '../ImageField';
|
import ImageField from '../ImageField';
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
import {
|
||||||
import DataGridMultiSelect from '../DataGridMultiSelect';
|
createColumnLoader,
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
ColumnMetadata,
|
||||||
|
} from '../DataGrid/configBuilderFactory';
|
||||||
|
|
||||||
import { hasPermission } from '../../helpers/userPermissions';
|
const USERS_COLUMNS: ColumnMetadata[] = [
|
||||||
import { logger } from '../../lib/logger';
|
{
|
||||||
|
field: 'firstName',
|
||||||
|
headerName: 'First Name',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'lastName', headerName: 'Last Name', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'phoneNumber',
|
||||||
|
headerName: 'Phone Number',
|
||||||
|
type: 'text',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{ field: 'email', headerName: 'E-Mail', type: 'text', editable: true },
|
||||||
|
{
|
||||||
|
field: 'disabled',
|
||||||
|
headerName: 'Disabled',
|
||||||
|
type: 'boolean',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'avatar',
|
||||||
|
headerName: 'Avatar',
|
||||||
|
type: 'image',
|
||||||
|
editable: false,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
|
<ImageField
|
||||||
|
name='Avatar'
|
||||||
|
image={params?.row?.avatar}
|
||||||
|
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'app_role',
|
||||||
|
headerName: 'App Role',
|
||||||
|
type: 'singleSelectRelation',
|
||||||
|
entityRef: 'roles',
|
||||||
|
editable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'custom_permissions',
|
||||||
|
headerName: 'Custom Permissions',
|
||||||
|
type: 'relationMany',
|
||||||
|
entityRef: 'permissions',
|
||||||
|
editable: false,
|
||||||
|
},
|
||||||
|
{ field: 'actions', headerName: '', type: 'actions' },
|
||||||
|
];
|
||||||
|
|
||||||
type Params = (id: string) => void;
|
export const loadColumns = createColumnLoader({
|
||||||
|
entityName: 'users',
|
||||||
export const loadColumns = async (
|
columns: USERS_COLUMNS,
|
||||||
onDelete: Params,
|
});
|
||||||
entityName: string,
|
|
||||||
|
|
||||||
user: unknown,
|
|
||||||
): Promise<GridColDef[]> => {
|
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
'Failed to fetch options',
|
|
||||||
error instanceof Error ? error : { error },
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_USERS');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
field: 'firstName',
|
|
||||||
headerName: 'First Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'lastName',
|
|
||||||
headerName: 'Last Name',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'phoneNumber',
|
|
||||||
headerName: 'Phone Number',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'email',
|
|
||||||
headerName: 'E-Mail',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'disabled',
|
|
||||||
headerName: 'Disabled',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'avatar',
|
|
||||||
headerName: 'Avatar',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: false,
|
|
||||||
sortable: false,
|
|
||||||
renderCell: (params) => (
|
|
||||||
<ImageField
|
|
||||||
name={'Avatar'}
|
|
||||||
image={params?.row?.avatar}
|
|
||||||
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'app_role',
|
|
||||||
headerName: 'App Role',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
getOptionValue: (value: any) => value?.id,
|
|
||||||
getOptionLabel: (value: any) => value?.label,
|
|
||||||
valueOptions: await callOptionsApi('roles'),
|
|
||||||
valueGetter: (value: { id?: string } | string | null) =>
|
|
||||||
(typeof value === 'object' && value !== null ? value?.id : value) ??
|
|
||||||
value,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'custom_permissions',
|
|
||||||
headerName: 'Custom Permissions',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
editable: false,
|
|
||||||
sortable: false,
|
|
||||||
type: 'singleSelect' as const,
|
|
||||||
valueFormatter: ({ value }) =>
|
|
||||||
dataFormatter.permissionsManyListFormatter(value).join(', '),
|
|
||||||
renderEditCell: (params) => (
|
|
||||||
<DataGridMultiSelect {...params} entityName={'permissions'} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
field: 'actions',
|
|
||||||
type: 'actions' as const,
|
|
||||||
minWidth: 30,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
getActions: (params: GridRowParams) => {
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
|
||||||
<ListActionsPopover
|
|
||||||
onDelete={onDelete}
|
|
||||||
itemId={params?.row?.id}
|
|
||||||
pathEdit={`/users/users-edit/?id=${params?.row?.id}`}
|
|
||||||
pathView={`/users/users-view/?id=${params?.row?.id}`}
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -32,9 +32,11 @@ export type FormFieldType =
|
|||||||
| 'textarea'
|
| 'textarea'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'selectMany'
|
| 'selectMany'
|
||||||
|
| 'enumSelect'
|
||||||
| 'switch'
|
| 'switch'
|
||||||
| 'image'
|
| 'image'
|
||||||
| 'date'
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
| 'password'
|
| 'password'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
@ -256,6 +258,17 @@ function renderField<T>(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'enumSelect':
|
||||||
|
return (
|
||||||
|
<Field name={name} id={name} component='select'>
|
||||||
|
{options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
case 'selectMany':
|
case 'selectMany':
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@ -293,6 +306,15 @@ function renderField<T>(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'datetime':
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
type='datetime-local'
|
||||||
|
placeholder={placeholder || field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'custom':
|
case 'custom':
|
||||||
if (CustomComponent) {
|
if (CustomComponent) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
export default {
|
const dataFormatter = {
|
||||||
filesFormatter(arr) {
|
filesFormatter(arr) {
|
||||||
if (!arr || !arr.length) return [];
|
if (!arr || !arr.length) return [];
|
||||||
return arr.map((item) => item);
|
return arr.map((item) => item);
|
||||||
@ -172,3 +172,5 @@ export default {
|
|||||||
return { label: val.name, id: val.id };
|
return { label: val.name, id: val.id };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default dataFormatter;
|
||||||
|
|||||||
53
frontend/src/helpers/textFormatters.ts
Normal file
53
frontend/src/helpers/textFormatters.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Text Formatting Helpers
|
||||||
|
*
|
||||||
|
* Common text transformation utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a plural title to singular by removing the last character.
|
||||||
|
* Simple approach for entity names like "roles" -> "role".
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* singularize('View roles') // 'View role'
|
||||||
|
* singularize('assets') // 'asset'
|
||||||
|
*/
|
||||||
|
export function singularize(pluralTitle: string): string {
|
||||||
|
return pluralTitle.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalize the first letter of a string.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* capitalize('hello') // 'Hello'
|
||||||
|
*/
|
||||||
|
export function capitalize(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert snake_case to Title Case.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* snakeToTitle('tour_pages') // 'Tour Pages'
|
||||||
|
*/
|
||||||
|
export function snakeToTitle(str: string): string {
|
||||||
|
return str.split('_').map(capitalize).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert camelCase to Title Case.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* camelToTitle('tourPages') // 'Tour Pages'
|
||||||
|
*/
|
||||||
|
export function camelToTitle(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/([A-Z])/g, ' $1')
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.map(capitalize)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Zod Adapter - Converts Zod schemas to Formik validation functions
|
|
||||||
* Provides type-safe form validation with Zod v4
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as z from 'zod';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a Zod schema to a Formik validate function
|
|
||||||
*
|
|
||||||
* @param schema - Zod schema to use for validation
|
|
||||||
* @returns Formik-compatible validate function
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const userSchema = z.object({
|
|
||||||
* email: z.string().email('Invalid email'),
|
|
||||||
* firstName: z.string().min(1, 'Required'),
|
|
||||||
* })
|
|
||||||
*
|
|
||||||
* <Formik
|
|
||||||
* validate={toFormikValidate(userSchema)}
|
|
||||||
* initialValues={{ email: '', firstName: '' }}
|
|
||||||
* >
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function toFormikValidate<T extends z.ZodType>(
|
|
||||||
schema: T,
|
|
||||||
): (values: z.infer<T>) => Record<string, string> {
|
|
||||||
return (values: z.infer<T>) => {
|
|
||||||
const result = z.safeParse(schema, values);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (result.error && 'issues' in result.error) {
|
|
||||||
result.error.issues.forEach((issue) => {
|
|
||||||
// Join path for nested fields (e.g., 'address.street')
|
|
||||||
const path = issue.path.map(String).join('.');
|
|
||||||
|
|
||||||
// Only set the first error for each field
|
|
||||||
if (!errors[path]) {
|
|
||||||
errors[path] = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async version of toFormikValidate for schemas with async refinements
|
|
||||||
*
|
|
||||||
* @param schema - Zod schema to use for validation
|
|
||||||
* @returns Formik-compatible async validate function
|
|
||||||
*/
|
|
||||||
export function toFormikValidateAsync<T extends z.ZodType>(
|
|
||||||
schema: T,
|
|
||||||
): (values: z.infer<T>) => Promise<Record<string, string>> {
|
|
||||||
return async (values: z.infer<T>) => {
|
|
||||||
const result = await z.safeParseAsync(schema, values);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (result.error && 'issues' in result.error) {
|
|
||||||
result.error.issues.forEach((issue) => {
|
|
||||||
const path = issue.path.map(String).join('.');
|
|
||||||
|
|
||||||
if (!errors[path]) {
|
|
||||||
errors[path] = issue.message;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default toFormikValidate;
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import useSWR from 'swr';
|
|
||||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
|
||||||
|
|
||||||
export const useSampleClients = () => {
|
|
||||||
const { data, error } = useSWR('/data-sources/clients.json', fetcher);
|
|
||||||
|
|
||||||
return {
|
|
||||||
clients: data?.data ?? [],
|
|
||||||
isLoading: !error && !data,
|
|
||||||
isError: error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSampleTransactions = () => {
|
|
||||||
const { data, error } = useSWR('/data-sources/history.json', fetcher);
|
|
||||||
|
|
||||||
return {
|
|
||||||
transactions: data?.data ?? [],
|
|
||||||
isLoading: !error && !data,
|
|
||||||
isError: error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
308
frontend/src/hooks/useDashboardCounts.ts
Normal file
308
frontend/src/hooks/useDashboardCounts.ts
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* useDashboardCounts Hook
|
||||||
|
*
|
||||||
|
* Provides a centralized way to fetch entity counts for the dashboard
|
||||||
|
* with permission-based filtering and proper error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
|
// User type from auth state (currentUser)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type User = any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity configuration for dashboard cards
|
||||||
|
*/
|
||||||
|
export interface EntityConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
endpoint: string;
|
||||||
|
permission: string;
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard entity configurations
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_ENTITIES: EntityConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
label: 'Users',
|
||||||
|
endpoint: '/users/count',
|
||||||
|
permission: 'READ_USERS',
|
||||||
|
href: '/users/users-list',
|
||||||
|
icon: 'mdiAccountGroup',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: 'Roles',
|
||||||
|
endpoint: '/roles/count',
|
||||||
|
permission: 'READ_ROLES',
|
||||||
|
href: '/roles/roles-list',
|
||||||
|
icon: 'mdiShieldAccountVariantOutline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'permissions',
|
||||||
|
label: 'Permissions',
|
||||||
|
endpoint: '/permissions/count',
|
||||||
|
permission: 'READ_PERMISSIONS',
|
||||||
|
href: '/permissions/permissions-list',
|
||||||
|
icon: 'mdiShieldAccountOutline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'projects',
|
||||||
|
label: 'Projects',
|
||||||
|
endpoint: '/projects/count',
|
||||||
|
permission: 'READ_PROJECTS',
|
||||||
|
href: '/projects/projects-list',
|
||||||
|
icon: 'mdiOfficeBuilding',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project_memberships',
|
||||||
|
label: 'Project memberships',
|
||||||
|
endpoint: '/project_memberships/count',
|
||||||
|
permission: 'READ_PROJECT_MEMBERSHIPS',
|
||||||
|
href: '/project_memberships/project_memberships-list',
|
||||||
|
icon: 'mdiAccountKey',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'assets',
|
||||||
|
label: 'Assets',
|
||||||
|
endpoint: '/assets/count',
|
||||||
|
permission: 'READ_ASSETS',
|
||||||
|
href: '/assets/assets-list',
|
||||||
|
icon: 'mdiFolderMultipleImage',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'asset_variants',
|
||||||
|
label: 'Asset variants',
|
||||||
|
endpoint: '/asset_variants/count',
|
||||||
|
permission: 'READ_ASSET_VARIANTS',
|
||||||
|
href: '/asset_variants/asset_variants-list',
|
||||||
|
icon: 'mdiImageMultiple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'presigned_url_requests',
|
||||||
|
label: 'Presigned url requests',
|
||||||
|
endpoint: '/presigned_url_requests/count',
|
||||||
|
permission: 'READ_PRESIGNED_URL_REQUESTS',
|
||||||
|
href: '/presigned_url_requests/presigned_url_requests-list',
|
||||||
|
icon: 'mdiLinkLock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tour_pages',
|
||||||
|
label: 'Tour pages',
|
||||||
|
endpoint: '/tour_pages/count',
|
||||||
|
permission: 'READ_TOUR_PAGES',
|
||||||
|
href: '/tour_pages/tour_pages-list',
|
||||||
|
icon: 'mdiFileDocumentMultiple',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'project_audio_tracks',
|
||||||
|
label: 'Project audio tracks',
|
||||||
|
endpoint: '/project_audio_tracks/count',
|
||||||
|
permission: 'READ_PROJECT_AUDIO_TRACKS',
|
||||||
|
href: '/project_audio_tracks/project_audio_tracks-list',
|
||||||
|
icon: 'mdiMusicNote',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publish_events',
|
||||||
|
label: 'Publish events',
|
||||||
|
endpoint: '/publish_events/count',
|
||||||
|
permission: 'READ_PUBLISH_EVENTS',
|
||||||
|
href: '/publish_events/publish_events-list',
|
||||||
|
icon: 'mdiPublish',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pwa_caches',
|
||||||
|
label: 'Pwa caches',
|
||||||
|
endpoint: '/pwa_caches/count',
|
||||||
|
permission: 'READ_PWA_CACHES',
|
||||||
|
href: '/pwa_caches/pwa_caches-list',
|
||||||
|
icon: 'mdiCellphoneLink',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'access_logs',
|
||||||
|
label: 'Access logs',
|
||||||
|
endpoint: '/access_logs/count',
|
||||||
|
permission: 'READ_ACCESS_LOGS',
|
||||||
|
href: '/access_logs/access_logs-list',
|
||||||
|
icon: 'mdiClipboardTextOutline',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type EntityCountValue = number | string | null;
|
||||||
|
|
||||||
|
export interface DashboardCountsState {
|
||||||
|
counts: Record<string, EntityCountValue>;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDashboardCountsReturn extends DashboardCountsState {
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
getCount: (key: string) => EntityCountValue;
|
||||||
|
getVisibleEntities: () => EntityConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADING_MESSAGE = 'Loading...';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching dashboard entity counts with permission-based filtering
|
||||||
|
*
|
||||||
|
* @param currentUser - The current authenticated user
|
||||||
|
* @returns Dashboard counts state and helper functions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { counts, loading, getVisibleEntities, getCount } = useDashboardCounts(currentUser);
|
||||||
|
*
|
||||||
|
* // Get visible entities for the user's permissions
|
||||||
|
* const entities = getVisibleEntities();
|
||||||
|
*
|
||||||
|
* // Get count for a specific entity
|
||||||
|
* const userCount = getCount('users'); // number | 'Loading...' | null
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useDashboardCounts(
|
||||||
|
currentUser: User | null,
|
||||||
|
): UseDashboardCountsReturn {
|
||||||
|
const [state, setState] = useState<DashboardCountsState>({
|
||||||
|
counts: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track if component is mounted
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
// Initialize counts with loading message for permitted entities
|
||||||
|
const initializeCounts = useCallback(
|
||||||
|
(user: User | null): Record<string, EntityCountValue> => {
|
||||||
|
const initial: Record<string, EntityCountValue> = {};
|
||||||
|
for (const entity of DASHBOARD_ENTITIES) {
|
||||||
|
if (hasPermission(user, entity.permission)) {
|
||||||
|
initial[entity.key] = LOADING_MESSAGE;
|
||||||
|
} else {
|
||||||
|
initial[entity.key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch counts from API
|
||||||
|
const fetchCounts = useCallback(async () => {
|
||||||
|
if (!currentUser) {
|
||||||
|
setState({
|
||||||
|
counts: initializeCounts(null),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
counts: initializeCounts(currentUser),
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build array of fetch promises for permitted entities
|
||||||
|
const fetchPromises = DASHBOARD_ENTITIES.map(async (entity) => {
|
||||||
|
if (!hasPermission(currentUser, entity.permission)) {
|
||||||
|
return { key: entity.key, count: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(entity.endpoint);
|
||||||
|
return {
|
||||||
|
key: entity.key,
|
||||||
|
count: response.data.count as number,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
key: entity.key,
|
||||||
|
count: null,
|
||||||
|
error: err instanceof Error ? err.message : 'Failed to fetch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use Promise.allSettled for resilience - don't fail if some counts fail
|
||||||
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
|
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
const newCounts: Record<string, EntityCountValue> = {};
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const { key, count, error } = result.value;
|
||||||
|
newCounts[key] = error ? error : count;
|
||||||
|
} else {
|
||||||
|
// This shouldn't happen since we catch errors inside the promise
|
||||||
|
// but handle it for completeness - silently ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setState({
|
||||||
|
counts: newCounts,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: err instanceof Error ? err : new Error('Failed to fetch counts'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [currentUser, initializeCounts]);
|
||||||
|
|
||||||
|
// Fetch counts when user changes
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
fetchCounts();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [fetchCounts]);
|
||||||
|
|
||||||
|
// Get count for a specific entity
|
||||||
|
const getCount = useCallback(
|
||||||
|
(key: string): EntityCountValue => {
|
||||||
|
return state.counts[key] ?? null;
|
||||||
|
},
|
||||||
|
[state.counts],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get entities visible to the current user
|
||||||
|
const getVisibleEntities = useCallback((): EntityConfig[] => {
|
||||||
|
return DASHBOARD_ENTITIES.filter((entity) =>
|
||||||
|
hasPermission(currentUser, entity.permission),
|
||||||
|
);
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts: state.counts,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
refetch: fetchCounts,
|
||||||
|
getCount,
|
||||||
|
getVisibleEntities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDashboardCounts;
|
||||||
166
frontend/src/hooks/useEditPageSync.ts
Normal file
166
frontend/src/hooks/useEditPageSync.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* useEditPageSync Hook
|
||||||
|
*
|
||||||
|
* Handles the common pattern in edit pages:
|
||||||
|
* 1. Fetch entity when ID is available
|
||||||
|
* 2. Sync form values when entity data changes
|
||||||
|
*
|
||||||
|
* Reduces ~50 lines of duplicated code across edit pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { AsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
import type { RootState } from '../stores/store';
|
||||||
|
|
||||||
|
interface UseEditPageSyncOptions<T extends Record<string, unknown>> {
|
||||||
|
/** Redux selector to get the entity from state (can return single entity, array, or any slice state value) */
|
||||||
|
entitySelector: (state: RootState) => unknown;
|
||||||
|
/** Redux async thunk to fetch the entity */
|
||||||
|
fetchAction: AsyncThunk<unknown, { id?: string; query?: string }, object>;
|
||||||
|
/** Initial form values */
|
||||||
|
initialValues: T;
|
||||||
|
/** Optional post-processing of entity data before setting form values */
|
||||||
|
postProcess?: (entity: T, initial: T) => T;
|
||||||
|
/** Optional ID override (defaults to router.query.id) */
|
||||||
|
idOverride?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseEditPageSyncReturn<T> {
|
||||||
|
/** Current form values */
|
||||||
|
values: T;
|
||||||
|
/** Set form values directly */
|
||||||
|
setValues: React.Dispatch<React.SetStateAction<T>>;
|
||||||
|
/** Entity ID from router */
|
||||||
|
id: string | null;
|
||||||
|
/** Whether initial fetch is loading */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Whether entity was found */
|
||||||
|
isFound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for syncing edit page forms with Redux entity state
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const initVals = { name: '', permissions: [] };
|
||||||
|
*
|
||||||
|
* const EditRolesPage = () => {
|
||||||
|
* const { values, id, isLoading } = useEditPageSync({
|
||||||
|
* entitySelector: (state) => state.roles.roles,
|
||||||
|
* fetchAction: fetch,
|
||||||
|
* initialValues: initVals,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleSubmit = async (data) => {
|
||||||
|
* await dispatch(update({ id, data }));
|
||||||
|
* router.push('/roles/roles-list');
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <Formik enableReinitialize initialValues={values} onSubmit={handleSubmit}>
|
||||||
|
* ...
|
||||||
|
* </Formik>
|
||||||
|
* );
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export function useEditPageSync<T extends Record<string, unknown>>(
|
||||||
|
options: UseEditPageSyncOptions<T>,
|
||||||
|
): UseEditPageSyncReturn<T> {
|
||||||
|
const {
|
||||||
|
entitySelector,
|
||||||
|
fetchAction,
|
||||||
|
initialValues,
|
||||||
|
postProcess,
|
||||||
|
idOverride,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get ID from router or override
|
||||||
|
const routerId = router.query.id;
|
||||||
|
const id =
|
||||||
|
idOverride ?? (Array.isArray(routerId) ? routerId[0] : routerId) ?? null;
|
||||||
|
|
||||||
|
// Local state for form values
|
||||||
|
const [values, setValues] = useState<T>(initialValues);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isFound, setIsFound] = useState(false);
|
||||||
|
|
||||||
|
// Get entity from Redux store
|
||||||
|
const rawEntity = useAppSelector(entitySelector);
|
||||||
|
|
||||||
|
// Handle both array and single entity - when single entity, it's stored directly
|
||||||
|
// When array, it means we fetched a list (shouldn't happen in edit pages)
|
||||||
|
// Filter out primitive types (number, boolean, string) from the slice state
|
||||||
|
const entity = (() => {
|
||||||
|
if (rawEntity === null || rawEntity === undefined) return null;
|
||||||
|
if (typeof rawEntity !== 'object') return null; // Filter out primitives
|
||||||
|
if (Array.isArray(rawEntity)) {
|
||||||
|
return rawEntity.length === 1 ? (rawEntity[0] as T) : null;
|
||||||
|
}
|
||||||
|
return rawEntity as T;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Fetch entity when ID changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
setIsLoading(true);
|
||||||
|
dispatch(fetchAction({ id }))
|
||||||
|
.then(() => setIsLoading(false))
|
||||||
|
.catch(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
}, [id, dispatch, fetchAction]);
|
||||||
|
|
||||||
|
// Sync form values when entity changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (entity && typeof entity === 'object' && !Array.isArray(entity)) {
|
||||||
|
// Build new values by copying from entity to initial structure
|
||||||
|
const newValues = { ...initialValues };
|
||||||
|
|
||||||
|
Object.keys(initialValues).forEach((key) => {
|
||||||
|
if (key in entity) {
|
||||||
|
(newValues as Record<string, unknown>)[key] = entity[key as keyof T];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply post-processing if provided
|
||||||
|
const finalValues = postProcess
|
||||||
|
? postProcess(newValues, initialValues)
|
||||||
|
: newValues;
|
||||||
|
|
||||||
|
setValues(finalValues);
|
||||||
|
setIsFound(true);
|
||||||
|
}
|
||||||
|
}, [entity, initialValues, postProcess]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
setValues,
|
||||||
|
id,
|
||||||
|
isLoading,
|
||||||
|
isFound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified version that returns just values tuple
|
||||||
|
* for drop-in replacement in existing code
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [initialValues, setInitialValues] = useEditPageSyncSimple({
|
||||||
|
* entitySelector: (state) => state.roles.roles,
|
||||||
|
* fetchAction: fetch,
|
||||||
|
* initialValues: initVals,
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useEditPageSyncSimple<T extends Record<string, unknown>>(
|
||||||
|
options: UseEditPageSyncOptions<T>,
|
||||||
|
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||||
|
const { values, setValues } = useEditPageSync(options);
|
||||||
|
return [values, setValues];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useEditPageSync;
|
||||||
88
frontend/src/lib/slugHelpers.ts
Normal file
88
frontend/src/lib/slugHelpers.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Slug Helpers
|
||||||
|
*
|
||||||
|
* Utilities for generating and validating URL slugs
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex pattern for valid slugs
|
||||||
|
* Matches: lowercase alphanumeric with hyphens between segments
|
||||||
|
* Examples: "my-page", "page-2", "about"
|
||||||
|
*/
|
||||||
|
export const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string into a valid URL slug
|
||||||
|
*
|
||||||
|
* @param value - Raw string to sanitize
|
||||||
|
* @returns Sanitized slug string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* sanitizeSlug('My Page Title') // 'my-page-title'
|
||||||
|
* sanitizeSlug('Hello World!') // 'hello-world'
|
||||||
|
* sanitizeSlug('--test--') // 'test'
|
||||||
|
*/
|
||||||
|
export function sanitizeSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a unique slug from a base value, avoiding collisions with existing slugs
|
||||||
|
*
|
||||||
|
* @param baseValue - The desired slug base
|
||||||
|
* @param usedSlugs - Set of already-used slugs
|
||||||
|
* @returns A unique slug string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const used = new Set(['my-page', 'my-page-2']);
|
||||||
|
* buildUniqueSlug('my-page', used) // 'my-page-3'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const used = new Set(['about']);
|
||||||
|
* buildUniqueSlug('About', used) // 'about-2'
|
||||||
|
*/
|
||||||
|
export function buildUniqueSlug(
|
||||||
|
baseValue: string,
|
||||||
|
usedSlugs: Set<string>,
|
||||||
|
): string {
|
||||||
|
const baseSlug = sanitizeSlug(baseValue) || 'page';
|
||||||
|
if (!usedSlugs.has(baseSlug)) return baseSlug;
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (usedSlugs.has(`${baseSlug}-${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseSlug}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a unique slug from an array of existing slugs
|
||||||
|
*
|
||||||
|
* @param baseValue - The desired slug base
|
||||||
|
* @param existingSlugs - Array of already-used slugs
|
||||||
|
* @returns A unique slug string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* buildUniqueSlugFromArray('my-page', ['my-page', 'my-page-2']) // 'my-page-3'
|
||||||
|
*/
|
||||||
|
export function buildUniqueSlugFromArray(
|
||||||
|
baseValue: string,
|
||||||
|
existingSlugs: string[],
|
||||||
|
): string {
|
||||||
|
return buildUniqueSlug(baseValue, new Set(existingSlugs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a string is a valid slug
|
||||||
|
*
|
||||||
|
* @param value - String to validate
|
||||||
|
* @returns true if valid slug format
|
||||||
|
*/
|
||||||
|
export function isValidSlug(value: string): boolean {
|
||||||
|
return slugPattern.test(value);
|
||||||
|
}
|
||||||
118
frontend/src/lib/tourFlowHelpers.ts
Normal file
118
frontend/src/lib/tourFlowHelpers.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Tour Flow Helpers
|
||||||
|
*
|
||||||
|
* Utilities for tour page routing and project handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a value to a valid route path
|
||||||
|
*
|
||||||
|
* @param value - Raw path value
|
||||||
|
* @returns Normalized path starting with /
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* toRoutePath('my-page') // '/my-page'
|
||||||
|
* toRoutePath('//double//slash') // '/double/slash'
|
||||||
|
* toRoutePath('http://example.com/page') // '/page'
|
||||||
|
* toRoutePath('') // '/'
|
||||||
|
*/
|
||||||
|
export function toRoutePath(value?: string): string {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw || raw === '/') return '/';
|
||||||
|
|
||||||
|
const normalized = raw
|
||||||
|
.replace(/^[^/]+:\/\//, '') // Remove protocol
|
||||||
|
.replace(/^\/*/, '') // Remove leading slashes
|
||||||
|
.replace(/\/{2,}/g, '/'); // Collapse multiple slashes
|
||||||
|
|
||||||
|
const withSlash = `/${normalized}`;
|
||||||
|
return withSlash.length > 1 ? withSlash.replace(/\/$/, '') : withSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a route path into segments
|
||||||
|
*
|
||||||
|
* @param value - Route path
|
||||||
|
* @returns Array of path segments
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* routeParts('/my/page/path') // ['my', 'page', 'path']
|
||||||
|
* routeParts('/') // []
|
||||||
|
*/
|
||||||
|
export function routeParts(value?: string): string[] {
|
||||||
|
return toRoutePath(value).split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two route paths for sorting
|
||||||
|
*
|
||||||
|
* @param a - First route path
|
||||||
|
* @param b - Second route path
|
||||||
|
* @returns Negative if a < b, positive if a > b, 0 if equal
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* compareRoutes('/a', '/b') // negative
|
||||||
|
* compareRoutes('/z', '/a') // positive
|
||||||
|
* compareRoutes('/page', '/page') // 0
|
||||||
|
*/
|
||||||
|
export function compareRoutes(a?: string, b?: string): number {
|
||||||
|
const aPath = toRoutePath(a);
|
||||||
|
const bPath = toRoutePath(b);
|
||||||
|
if (aPath === bPath) return 0;
|
||||||
|
|
||||||
|
const aParts = routeParts(aPath);
|
||||||
|
const bParts = routeParts(bPath);
|
||||||
|
const maxLen = Math.max(aParts.length, bParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < maxLen; index += 1) {
|
||||||
|
const aPart = aParts[index];
|
||||||
|
const bPart = bParts[index];
|
||||||
|
|
||||||
|
if (aPart === undefined) return -1;
|
||||||
|
if (bPart === undefined) return 1;
|
||||||
|
|
||||||
|
const compare = aPart.localeCompare(bPart);
|
||||||
|
if (compare !== 0) return compare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aParts.length - bParts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item that may contain a projectId
|
||||||
|
*/
|
||||||
|
interface ProjectIdHolder {
|
||||||
|
projectId?: string;
|
||||||
|
project?: { id?: string } | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract project ID from an item that may have it in different formats
|
||||||
|
*
|
||||||
|
* @param item - Object that may contain projectId in various formats
|
||||||
|
* @returns The project ID string or empty string if not found
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getProjectId({ projectId: '123' }) // '123'
|
||||||
|
* getProjectId({ project: '456' }) // '456'
|
||||||
|
* getProjectId({ project: { id: '789' } }) // '789'
|
||||||
|
* getProjectId({}) // ''
|
||||||
|
*/
|
||||||
|
export function getProjectId(item: ProjectIdHolder): string {
|
||||||
|
if (item.projectId) return item.projectId;
|
||||||
|
if (typeof item.project === 'string') return item.project;
|
||||||
|
if (item.project?.id) return item.project.id;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rows from API response with safety check
|
||||||
|
*
|
||||||
|
* @param response - API response object
|
||||||
|
* @returns Array of rows or empty array
|
||||||
|
*/
|
||||||
|
export function getRows<T = unknown>(
|
||||||
|
response: { data?: { rows?: T[] } } | null | undefined,
|
||||||
|
): T[] {
|
||||||
|
return Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Edit Access Logs Page
|
* Edit Access Logs Page
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
|
import { update, fetch } from '../../stores/access_logs/access_logsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { AccessLog } from '../../types/entities';
|
import type { AccessLog } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -41,36 +41,24 @@ const initVals = {
|
|||||||
const EditAccess_logsPage = () => {
|
const EditAccess_logsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const { access_logs } = useAppSelector((state) => state.access_logs);
|
const {
|
||||||
const { id } = router.query;
|
values: initialValues,
|
||||||
|
setValues,
|
||||||
// Fetch entity data
|
id,
|
||||||
useEffect(() => {
|
} = useEditPageSync({
|
||||||
if (id) {
|
entitySelector: (state) => state.access_logs.access_logs,
|
||||||
dispatch(fetch({ id: id as string }));
|
fetchAction: fetch,
|
||||||
}
|
initialValues: initVals,
|
||||||
}, [id, dispatch]);
|
});
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof access_logs === 'object' && access_logs !== null) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in access_logs) {
|
|
||||||
newInitialVal[key] = access_logs[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [access_logs]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
await dispatch(
|
if (id) {
|
||||||
update({ id: id as string, data: data as unknown as Partial<AccessLog> }),
|
await dispatch(
|
||||||
);
|
update({ id, data: data as unknown as Partial<AccessLog> }),
|
||||||
await router.push('/access_logs/access_logs-list');
|
);
|
||||||
|
await router.push('/access_logs/access_logs-list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -153,7 +141,7 @@ const EditAccess_logsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
accessed_at: date || new Date(),
|
accessed_at: date || new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
/**
|
|
||||||
* Edit Asset Variants Page
|
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -21,8 +16,9 @@ import BaseButton from '../../components/BaseButton';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
|
import { update, fetch } from '../../stores/asset_variants/asset_variantsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { AssetVariant } from '../../types/entities';
|
import type { AssetVariant } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -37,46 +33,17 @@ const initVals = {
|
|||||||
const EditAsset_variantsPage = () => {
|
const EditAsset_variantsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const assetVariantsState = useAppSelector((state) => state.asset_variants);
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
const asset_variants = assetVariantsState.asset_variants as
|
entitySelector: (state) => state.asset_variants.asset_variants,
|
||||||
| AssetVariant
|
fetchAction: fetch,
|
||||||
| AssetVariant[]
|
initialValues: initVals,
|
||||||
| undefined;
|
});
|
||||||
const assetVariant = Array.isArray(asset_variants)
|
|
||||||
? asset_variants[0]
|
|
||||||
: asset_variants;
|
|
||||||
|
|
||||||
const { id } = router.query;
|
|
||||||
const idStr = Array.isArray(id) ? id[0] : id;
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (idStr) {
|
|
||||||
dispatch(fetch({ id: idStr }));
|
|
||||||
}
|
|
||||||
}, [idStr, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (assetVariant && typeof assetVariant === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in assetVariant) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
assetVariant as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [assetVariant]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (idStr) {
|
if (id) {
|
||||||
await dispatch(
|
await dispatch(
|
||||||
update({ id: idStr, data: data as unknown as Partial<AssetVariant> }),
|
update({ id, data: data as unknown as Partial<AssetVariant> }),
|
||||||
);
|
);
|
||||||
await router.push('/asset_variants/asset_variants-list');
|
await router.push('/asset_variants/asset_variants-list');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -20,8 +20,9 @@ import { SelectField } from '../../components/SelectField';
|
|||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/assets/assetsSlice';
|
import { update, fetch } from '../../stores/assets/assetsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { Asset } from '../../types/entities';
|
import type { Asset } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -45,42 +46,20 @@ const initVals = {
|
|||||||
const EditAssetsPage = () => {
|
const EditAssetsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const assetsState = useAppSelector((state) => state.assets);
|
const {
|
||||||
const assets = assetsState.assets as Asset | Asset[] | undefined;
|
values: initialValues,
|
||||||
const asset = Array.isArray(assets) ? assets[0] : assets;
|
setValues,
|
||||||
|
id,
|
||||||
const { id } = router.query;
|
} = useEditPageSync({
|
||||||
const idStr = Array.isArray(id) ? id[0] : id;
|
entitySelector: (state) => state.assets.assets,
|
||||||
|
fetchAction: fetch,
|
||||||
// Fetch asset data
|
initialValues: initVals,
|
||||||
useEffect(() => {
|
});
|
||||||
if (idStr) {
|
|
||||||
dispatch(fetch({ id: idStr }));
|
|
||||||
}
|
|
||||||
}, [idStr, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (asset && typeof asset === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((el) => {
|
|
||||||
if (el in asset) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[el] = (
|
|
||||||
asset as unknown as Record<string, unknown>
|
|
||||||
)[el];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [asset]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (idStr) {
|
if (id) {
|
||||||
await dispatch(
|
await dispatch(update({ id, data: data as unknown as Partial<Asset> }));
|
||||||
update({ id: idStr, data: data as unknown as Partial<Asset> }),
|
|
||||||
);
|
|
||||||
await router.push('/assets/assets-list');
|
await router.push('/assets/assets-list');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -213,7 +192,7 @@ const EditAssetsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
deleted_at_time: date,
|
deleted_at_time: date,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
import SectionMain from '../components/SectionMain';
|
import SectionMain from '../components/SectionMain';
|
||||||
@ -14,102 +13,92 @@ import { hasPermission } from '../helpers/userPermissions';
|
|||||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||||
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||||
|
import {
|
||||||
|
useDashboardCounts,
|
||||||
|
EntityCountValue,
|
||||||
|
} from '../hooks/useDashboardCounts';
|
||||||
|
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard card component for entity counts
|
||||||
|
*/
|
||||||
|
interface DashboardCardProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
count: EntityCountValue;
|
||||||
|
iconKey: string;
|
||||||
|
corners: string;
|
||||||
|
cardsStyle: string;
|
||||||
|
iconsColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardCard = ({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
iconKey,
|
||||||
|
corners,
|
||||||
|
cardsStyle,
|
||||||
|
iconsColor,
|
||||||
|
}: DashboardCardProps) => {
|
||||||
|
const iconPath =
|
||||||
|
(icon as Record<string, string>)[iconKey] ??
|
||||||
|
(icon as Record<string, string>)['mdiFormatListBulleted'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
<div
|
||||||
|
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
|
>
|
||||||
|
<div className='flex justify-between align-center'>
|
||||||
|
<div>
|
||||||
|
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className='text-3xl leading-tight font-semibold'>{count}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<BaseIcon
|
||||||
|
className={`${iconsColor}`}
|
||||||
|
w='w-16'
|
||||||
|
h='h-16'
|
||||||
|
size={48}
|
||||||
|
path={iconPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||||
|
|
||||||
const loadingMessage = 'Loading...';
|
// Use the centralized dashboard counts hook
|
||||||
|
const { getCount, getVisibleEntities } = useDashboardCounts(currentUser);
|
||||||
const [users, setUsers] = React.useState(loadingMessage);
|
|
||||||
const [roles, setRoles] = React.useState(loadingMessage);
|
|
||||||
const [permissions, setPermissions] = React.useState(loadingMessage);
|
|
||||||
const [projects, setProjects] = React.useState(loadingMessage);
|
|
||||||
const [project_memberships, setProject_memberships] =
|
|
||||||
React.useState(loadingMessage);
|
|
||||||
const [assets, setAssets] = React.useState(loadingMessage);
|
|
||||||
const [asset_variants, setAsset_variants] = React.useState(loadingMessage);
|
|
||||||
const [presigned_url_requests, setPresigned_url_requests] =
|
|
||||||
React.useState(loadingMessage);
|
|
||||||
const [tour_pages, setTour_pages] = React.useState(loadingMessage);
|
|
||||||
const [project_audio_tracks, setProject_audio_tracks] =
|
|
||||||
React.useState(loadingMessage);
|
|
||||||
const [publish_events, setPublish_events] = React.useState(loadingMessage);
|
|
||||||
const [pwa_caches, setPwa_caches] = React.useState(loadingMessage);
|
|
||||||
const [access_logs, setAccess_logs] = React.useState(loadingMessage);
|
|
||||||
|
|
||||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||||
role: { value: '', label: '' },
|
role: { value: '', label: '' },
|
||||||
});
|
});
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
|
||||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
|
||||||
|
|
||||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
|
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
|
||||||
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
|
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadData() {
|
async function getWidgets(roleId: string) {
|
||||||
const entities = [
|
|
||||||
'users',
|
|
||||||
'roles',
|
|
||||||
'permissions',
|
|
||||||
'projects',
|
|
||||||
'project_memberships',
|
|
||||||
'assets',
|
|
||||||
'asset_variants',
|
|
||||||
'presigned_url_requests',
|
|
||||||
'tour_pages',
|
|
||||||
'project_audio_tracks',
|
|
||||||
'publish_events',
|
|
||||||
'pwa_caches',
|
|
||||||
'access_logs',
|
|
||||||
];
|
|
||||||
const fns = [
|
|
||||||
setUsers,
|
|
||||||
setRoles,
|
|
||||||
setPermissions,
|
|
||||||
setProjects,
|
|
||||||
setProject_memberships,
|
|
||||||
setAssets,
|
|
||||||
setAsset_variants,
|
|
||||||
setPresigned_url_requests,
|
|
||||||
setTour_pages,
|
|
||||||
setProject_audio_tracks,
|
|
||||||
setPublish_events,
|
|
||||||
setPwa_caches,
|
|
||||||
setAccess_logs,
|
|
||||||
];
|
|
||||||
|
|
||||||
const requests = entities.map((entity, index) => {
|
|
||||||
if (hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
|
|
||||||
return axios.get(`/${entity.toLowerCase()}/count`);
|
|
||||||
} else {
|
|
||||||
fns[index](null);
|
|
||||||
return Promise.resolve({ data: { count: null } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.allSettled(requests).then((results) => {
|
|
||||||
results.forEach((result, i) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
fns[i](result.value.data.count);
|
|
||||||
} else {
|
|
||||||
fns[i](result.reason.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWidgets(roleId) {
|
|
||||||
await dispatch(fetchWidgets(roleId));
|
await dispatch(fetchWidgets(roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
loadData().then();
|
|
||||||
setWidgetsRole({
|
setWidgetsRole({
|
||||||
role: {
|
role: {
|
||||||
value: currentUser?.app_role?.id,
|
value: currentUser?.app_role?.id,
|
||||||
@ -123,6 +112,9 @@ const Dashboard = () => {
|
|||||||
getWidgets(widgetsRole?.role?.value || '').then();
|
getWidgets(widgetsRole?.role?.value || '').then();
|
||||||
}, [widgetsRole?.role?.value]);
|
}, [widgetsRole?.role?.value]);
|
||||||
|
|
||||||
|
// Get entities visible to current user
|
||||||
|
const visibleEntities = getVisibleEntities();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -147,7 +139,7 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
{!!rolesWidgets.length &&
|
{!!rolesWidgets.length &&
|
||||||
hasPermission(currentUser, 'CREATE_ROLES') && (
|
hasPermission(currentUser, 'CREATE_ROLES') && (
|
||||||
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
|
<p className='text-gray-500 dark:text-gray-400 mb-4'>
|
||||||
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -155,7 +147,7 @@ const Dashboard = () => {
|
|||||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||||
{(isFetchingQuery || loading) && (
|
{(isFetchingQuery || loading) && (
|
||||||
<div
|
<div
|
||||||
className={` ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}
|
className={` ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}
|
||||||
>
|
>
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
className={`${iconsColor} animate-spin mr-5`}
|
className={`${iconsColor} animate-spin mr-5`}
|
||||||
@ -180,450 +172,24 @@ const Dashboard = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
{!!rolesWidgets.length && <hr className='my-6' />}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id='dashboard'
|
id='dashboard'
|
||||||
className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'
|
className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'
|
||||||
>
|
>
|
||||||
{hasPermission(currentUser, 'READ_USERS') && (
|
{visibleEntities.map((entity) => (
|
||||||
<Link href={'/users/users-list'}>
|
<DashboardCard
|
||||||
<div
|
key={entity.key}
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
href={entity.href}
|
||||||
>
|
label={entity.label}
|
||||||
<div className='flex justify-between align-center'>
|
count={getCount(entity.key)}
|
||||||
<div>
|
iconKey={entity.icon}
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
corners={corners}
|
||||||
Users
|
cardsStyle={cardsStyle}
|
||||||
</div>
|
iconsColor={iconsColor}
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
/>
|
||||||
{users}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiAccountGroup'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_ROLES') && (
|
|
||||||
<Link href={'/roles/roles-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Roles
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{roles}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiShieldAccountVariantOutline'
|
|
||||||
] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PERMISSIONS') && (
|
|
||||||
<Link href={'/permissions/permissions-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Permissions
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{permissions}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiShieldAccountOutline'
|
|
||||||
] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PROJECTS') && (
|
|
||||||
<Link href={'/projects/projects-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Projects
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{projects}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiOfficeBuilding'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PROJECT_MEMBERSHIPS') && (
|
|
||||||
<Link href={'/project_memberships/project_memberships-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Project memberships
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{project_memberships}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiAccountKey'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_ASSETS') && (
|
|
||||||
<Link href={'/assets/assets-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Assets
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{assets}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFolderMultipleImage'
|
|
||||||
] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_ASSET_VARIANTS') && (
|
|
||||||
<Link href={'/asset_variants/asset_variants-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Asset variants
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{asset_variants}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiImageMultiple'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PRESIGNED_URL_REQUESTS') && (
|
|
||||||
<Link href={'/presigned_url_requests/presigned_url_requests-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Presigned url requests
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{presigned_url_requests}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiLinkLock'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_TOUR_PAGES') && (
|
|
||||||
<Link href={'/tour_pages/tour_pages-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Tour pages
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{tour_pages}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFileDocumentMultiple'
|
|
||||||
] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PROJECT_AUDIO_TRACKS') && (
|
|
||||||
<Link href={'/project_audio_tracks/project_audio_tracks-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Project audio tracks
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{project_audio_tracks}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiMusicNote'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PUBLISH_EVENTS') && (
|
|
||||||
<Link href={'/publish_events/publish_events-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Publish events
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{publish_events}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiPublish'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_PWA_CACHES') && (
|
|
||||||
<Link href={'/pwa_caches/pwa_caches-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Pwa caches
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{pwa_caches}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)['mdiCellphoneLink'] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasPermission(currentUser, 'READ_ACCESS_LOGS') && (
|
|
||||||
<Link href={'/access_logs/access_logs-list'}>
|
|
||||||
<div
|
|
||||||
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
|
|
||||||
>
|
|
||||||
<div className='flex justify-between align-center'>
|
|
||||||
<div>
|
|
||||||
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
|
|
||||||
Access logs
|
|
||||||
</div>
|
|
||||||
<div className='text-3xl leading-tight font-semibold'>
|
|
||||||
{access_logs}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiClipboardTextOutline'
|
|
||||||
] ??
|
|
||||||
(icon as Record<string, string>)[
|
|
||||||
'mdiFormatListBulleted'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
import {
|
|
||||||
mdiAccount,
|
|
||||||
mdiBallotOutline,
|
|
||||||
mdiGithub,
|
|
||||||
mdiMail,
|
|
||||||
mdiUpload,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import { ReactElement } from 'react';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import FormCheckRadio from '../components/FormCheckRadio';
|
|
||||||
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
|
|
||||||
import FormField from '../components/FormField';
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import SectionTitle from '../components/SectionTitle';
|
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
|
|
||||||
const FormsPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Forms')}</title>
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiBallotOutline}
|
|
||||||
title='Formik forms example'
|
|
||||||
main
|
|
||||||
>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
|
|
||||||
<CardBox>
|
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
fullname: 'John Doe',
|
|
||||||
email: 'john.doe@example.com',
|
|
||||||
phone: '',
|
|
||||||
color: 'green',
|
|
||||||
textarea: 'Hello',
|
|
||||||
}}
|
|
||||||
onSubmit={(values) => alert(JSON.stringify(values, null, 2))}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<FormField
|
|
||||||
label='Grouped with icons'
|
|
||||||
icons={[mdiAccount, mdiMail]}
|
|
||||||
>
|
|
||||||
<Field name='fullname' placeholder='Full name' />
|
|
||||||
<Field type='email' name='email' placeholder='Email' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label='With help line and labelFor'
|
|
||||||
labelFor='phone'
|
|
||||||
help='Help line comes here'
|
|
||||||
>
|
|
||||||
<Field name='phone' placeholder='Phone' id='phone' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='Dropdown' labelFor='color'>
|
|
||||||
<Field name='color' id='color' component='select'>
|
|
||||||
<option value='red'>Red</option>
|
|
||||||
<option value='green'>Green</option>
|
|
||||||
<option value='blue'>Blue</option>
|
|
||||||
</Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<FormField label='Textarea' hasTextareaHeight>
|
|
||||||
<Field
|
|
||||||
name='textarea'
|
|
||||||
as='textarea'
|
|
||||||
placeholder='Your text here'
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type='submit' color='info' label='Submit' />
|
|
||||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
|
|
||||||
<SectionTitle>Custom elements</SectionTitle>
|
|
||||||
|
|
||||||
<SectionMain>
|
|
||||||
<CardBox>
|
|
||||||
<Formik
|
|
||||||
initialValues={{
|
|
||||||
checkboxes: ['lorem'],
|
|
||||||
switches: ['lorem'],
|
|
||||||
radio: 'lorem',
|
|
||||||
}}
|
|
||||||
onSubmit={() => null}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<FormField label='Checkbox'>
|
|
||||||
<FormCheckRadioGroup>
|
|
||||||
<FormCheckRadio type='checkbox' label='Lorem'>
|
|
||||||
<Field type='checkbox' name='checkboxes' value='lorem' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
<FormCheckRadio type='checkbox' label='Ipsum'>
|
|
||||||
<Field type='checkbox' name='checkboxes' value='ipsum' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
<FormCheckRadio type='checkbox' label='Dolore'>
|
|
||||||
<Field type='checkbox' name='checkboxes' value='dolore' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
</FormCheckRadioGroup>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<FormField label='Radio'>
|
|
||||||
<FormCheckRadioGroup>
|
|
||||||
<FormCheckRadio type='radio' label='Lorem'>
|
|
||||||
<Field type='radio' name='radio' value='lorem' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
<FormCheckRadio type='radio' label='Ipsum'>
|
|
||||||
<Field type='radio' name='radio' value='ipsum' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
</FormCheckRadioGroup>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<FormField label='Switch'>
|
|
||||||
<FormCheckRadioGroup>
|
|
||||||
<FormCheckRadio type='switch' label='Lorem'>
|
|
||||||
<Field type='checkbox' name='switches' value='lorem' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
<FormCheckRadio type='switch' label='Ipsum'>
|
|
||||||
<Field type='checkbox' name='switches' value='ipsum' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
</FormCheckRadioGroup>
|
|
||||||
</FormField>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
<BaseDivider />
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
FormsPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FormsPage;
|
|
||||||
@ -1,11 +1,6 @@
|
|||||||
/**
|
|
||||||
* Edit Permissions Page
|
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -20,8 +15,9 @@ import BaseButtons from '../../components/BaseButtons';
|
|||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
name: '',
|
name: '',
|
||||||
@ -30,34 +26,18 @@ const initVals = {
|
|||||||
const EditPermissionsPage = () => {
|
const EditPermissionsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const { permissions } = useAppSelector((state) => state.permissions);
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
const { id } = router.query;
|
entitySelector: (state) => state.permissions.permissions,
|
||||||
|
fetchAction: fetch,
|
||||||
// Fetch entity data
|
initialValues: initVals,
|
||||||
useEffect(() => {
|
});
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id: id as string }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof permissions === 'object' && permissions !== null) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in permissions) {
|
|
||||||
newInitialVal[key] = permissions[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [permissions]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
await dispatch(update({ id: id as string, data }));
|
if (id) {
|
||||||
await router.push('/permissions/permissions-list');
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push('/permissions/permissions-list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Edit Presigned URL Requests Page
|
* Edit Presigned URL Requests Page
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -27,8 +26,9 @@ import {
|
|||||||
update,
|
update,
|
||||||
fetch,
|
fetch,
|
||||||
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
|
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { PresignedUrlRequest } from '../../types/entities';
|
import type { PresignedUrlRequest } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -46,42 +46,17 @@ const initVals = {
|
|||||||
const EditPresigned_url_requestsPage = () => {
|
const EditPresigned_url_requestsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const presignedState = useAppSelector(
|
const {
|
||||||
(state) => state.presigned_url_requests,
|
values: initialValues,
|
||||||
);
|
setValues,
|
||||||
const presigned_url_requests = presignedState.presigned_url_requests as
|
id,
|
||||||
| PresignedUrlRequest
|
} = useEditPageSync({
|
||||||
| PresignedUrlRequest[]
|
entitySelector: (state) =>
|
||||||
| undefined;
|
state.presigned_url_requests.presigned_url_requests,
|
||||||
const presignedRequest = Array.isArray(presigned_url_requests)
|
fetchAction: fetch,
|
||||||
? presigned_url_requests[0]
|
initialValues: initVals,
|
||||||
: presigned_url_requests;
|
});
|
||||||
|
|
||||||
const { id } = router.query as { id?: string };
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof presignedRequest === 'object' && presignedRequest !== null) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in presignedRequest) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
presignedRequest as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [presignedRequest]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -183,7 +158,7 @@ const EditPresigned_url_requestsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
expires_at: date || new Date(),
|
expires_at: date || new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
/**
|
|
||||||
* Edit Project Audio Tracks Page
|
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -25,8 +20,9 @@ import {
|
|||||||
update,
|
update,
|
||||||
fetch,
|
fetch,
|
||||||
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
|
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { ProjectAudioTrack } from '../../types/entities';
|
import type { ProjectAudioTrack } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -45,43 +41,12 @@ const initVals = {
|
|||||||
const EditProject_audio_tracksPage = () => {
|
const EditProject_audio_tracksPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const project_audio_tracksState = useAppSelector(
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
(state) => state.project_audio_tracks,
|
entitySelector: (state) => state.project_audio_tracks.project_audio_tracks,
|
||||||
);
|
fetchAction: fetch,
|
||||||
const project_audio_tracks =
|
initialValues: initVals,
|
||||||
project_audio_tracksState.project_audio_tracks as
|
});
|
||||||
| ProjectAudioTrack
|
|
||||||
| ProjectAudioTrack[]
|
|
||||||
| undefined;
|
|
||||||
const item = Array.isArray(project_audio_tracks)
|
|
||||||
? project_audio_tracks[0]
|
|
||||||
: project_audio_tracks;
|
|
||||||
|
|
||||||
const { id } = router.query as { id?: string };
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in item) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
item as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Edit Project Memberships Page
|
* Edit Project Memberships Page
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -28,8 +27,9 @@ import {
|
|||||||
update,
|
update,
|
||||||
fetch,
|
fetch,
|
||||||
} from '../../stores/project_memberships/project_membershipsSlice';
|
} from '../../stores/project_memberships/project_membershipsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { ProjectMembership } from '../../types/entities';
|
import type { ProjectMembership } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -44,42 +44,16 @@ const initVals = {
|
|||||||
const EditProject_membershipsPage = () => {
|
const EditProject_membershipsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const project_membershipsState = useAppSelector(
|
const {
|
||||||
(state) => state.project_memberships,
|
values: initialValues,
|
||||||
);
|
setValues,
|
||||||
const project_memberships = project_membershipsState.project_memberships as
|
id,
|
||||||
| ProjectMembership
|
} = useEditPageSync({
|
||||||
| ProjectMembership[]
|
entitySelector: (state) => state.project_memberships.project_memberships,
|
||||||
| undefined;
|
fetchAction: fetch,
|
||||||
const item = Array.isArray(project_memberships)
|
initialValues: initVals,
|
||||||
? project_memberships[0]
|
});
|
||||||
: project_memberships;
|
|
||||||
|
|
||||||
const { id } = router.query as { id?: string };
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in item) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
item as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -163,7 +137,7 @@ const EditProject_membershipsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
invited_at: date,
|
invited_at: date,
|
||||||
})
|
})
|
||||||
@ -185,7 +159,7 @@ const EditProject_membershipsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
accepted_at: date,
|
accepted_at: date,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Edit Publish Events Page
|
* Edit Publish Events Page
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
|
|||||||
import { SelectField } from '../../components/SelectField';
|
import { SelectField } from '../../components/SelectField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/publish_events/publish_eventsSlice';
|
import { update, fetch } from '../../stores/publish_events/publish_eventsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { PublishEvent } from '../../types/entities';
|
import type { PublishEvent } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -45,40 +45,16 @@ const initVals = {
|
|||||||
const EditPublish_eventsPage = () => {
|
const EditPublish_eventsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const publish_eventsState = useAppSelector((state) => state.publish_events);
|
const {
|
||||||
const publish_events = publish_eventsState.publish_events as
|
values: initialValues,
|
||||||
| PublishEvent
|
setValues,
|
||||||
| PublishEvent[]
|
id,
|
||||||
| undefined;
|
} = useEditPageSync({
|
||||||
const item = Array.isArray(publish_events)
|
entitySelector: (state) => state.publish_events.publish_events,
|
||||||
? publish_events[0]
|
fetchAction: fetch,
|
||||||
: publish_events;
|
initialValues: initVals,
|
||||||
|
});
|
||||||
const { id } = router.query as { id?: string };
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (item && typeof item === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in item) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
item as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@ -169,7 +145,7 @@ const EditPublish_eventsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
started_at: date,
|
started_at: date,
|
||||||
})
|
})
|
||||||
@ -191,7 +167,7 @@ const EditPublish_eventsPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
finished_at: date,
|
finished_at: date,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Edit PWA Caches Page
|
* Edit PWA Caches Page
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -25,8 +24,9 @@ import { SelectField } from '../../components/SelectField';
|
|||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/pwa_caches/pwa_cachesSlice';
|
import { update, fetch } from '../../stores/pwa_caches/pwa_cachesSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
project: null,
|
project: null,
|
||||||
@ -41,34 +41,22 @@ const initVals = {
|
|||||||
const EditPwa_cachesPage = () => {
|
const EditPwa_cachesPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const { pwa_caches } = useAppSelector((state) => state.pwa_caches);
|
const {
|
||||||
const { id } = router.query;
|
values: initialValues,
|
||||||
|
setValues,
|
||||||
// Fetch entity data
|
id,
|
||||||
useEffect(() => {
|
} = useEditPageSync({
|
||||||
if (id) {
|
entitySelector: (state) => state.pwa_caches.pwa_caches,
|
||||||
dispatch(fetch({ id: id as string }));
|
fetchAction: fetch,
|
||||||
}
|
initialValues: initVals,
|
||||||
}, [id, dispatch]);
|
});
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof pwa_caches === 'object' && pwa_caches !== null) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in pwa_caches) {
|
|
||||||
newInitialVal[key] = pwa_caches[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [pwa_caches]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
await dispatch(update({ id: id as string, data }));
|
if (id) {
|
||||||
await router.push('/pwa_caches/pwa_caches-list');
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push('/pwa_caches/pwa_caches-list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -143,7 +131,7 @@ const EditPwa_cachesPage = () => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onChange={(date: Date | null) =>
|
onChange={(date: Date | null) =>
|
||||||
setInitialValues({
|
setValues({
|
||||||
...initialValues,
|
...initialValues,
|
||||||
generated_at: date || new Date(),
|
generated_at: date || new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
/**
|
|
||||||
* Edit Roles Page
|
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -23,43 +18,28 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
|
|||||||
import { update, fetch } from '../../stores/roles/rolesSlice';
|
import { update, fetch } from '../../stores/roles/rolesSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
name: '',
|
name: '',
|
||||||
permissions: [],
|
permissions: [] as Array<{ id: string; name: string }>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditRolesPage = () => {
|
const EditRolesPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const { roles } = useAppSelector((state) => state.roles);
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
const { id } = router.query;
|
entitySelector: (state) => state.roles.roles,
|
||||||
|
fetchAction: fetch,
|
||||||
// Fetch entity data
|
initialValues: initVals,
|
||||||
useEffect(() => {
|
});
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id: id as string }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof roles === 'object' && roles !== null) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in roles) {
|
|
||||||
newInitialVal[key] = roles[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [roles]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
await dispatch(update({ id: id as string, data }));
|
if (id) {
|
||||||
await router.push('/roles/roles-list');
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push('/roles/roles-list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import React, { ReactElement } from 'react';
|
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
||||||
import SectionMain from '../components/SectionMain';
|
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
||||||
import TableSampleClients from '../components/TableSampleClients';
|
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
|
|
||||||
const TablesPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Tables')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiChartTimelineVariant}
|
|
||||||
title='Table'
|
|
||||||
main
|
|
||||||
>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
<CardBox className='mb-6' hasTable>
|
|
||||||
<TableSampleClients />
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
TablesPage.getLayout = function getLayout(page: ReactElement) {
|
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TablesPage;
|
|
||||||
@ -1,11 +1,6 @@
|
|||||||
/**
|
|
||||||
* Edit Tour Pages Page
|
|
||||||
* Cleaned up version with consolidated useEffect hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -22,8 +17,9 @@ import { SelectField } from '../../components/SelectField';
|
|||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/tour_pages/tour_pagesSlice';
|
import { update, fetch } from '../../stores/tour_pages/tour_pagesSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
import type { TourPage } from '../../types/entities';
|
import type { TourPage } from '../../types/entities';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
@ -44,44 +40,17 @@ const initVals = {
|
|||||||
const EditTour_pagesPage = () => {
|
const EditTour_pagesPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const tourPagesState = useAppSelector((state) => state.tour_pages);
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
const tour_pages = tourPagesState.tour_pages as
|
entitySelector: (state) => state.tour_pages.tour_pages,
|
||||||
| TourPage
|
fetchAction: fetch,
|
||||||
| TourPage[]
|
initialValues: initVals,
|
||||||
| undefined;
|
});
|
||||||
const tourPage = Array.isArray(tour_pages) ? tour_pages[0] : tour_pages;
|
|
||||||
|
|
||||||
const { id } = router.query as { id?: string };
|
|
||||||
const idStr = Array.isArray(id) ? id[0] : id;
|
|
||||||
|
|
||||||
// Fetch entity data
|
|
||||||
useEffect(() => {
|
|
||||||
if (idStr) {
|
|
||||||
dispatch(fetch({ id: idStr }));
|
|
||||||
}
|
|
||||||
}, [idStr, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (tourPage && typeof tourPage === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((key) => {
|
|
||||||
if (key in tourPage) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[key] = (
|
|
||||||
tourPage as unknown as Record<string, unknown>
|
|
||||||
)[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [tourPage]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
if (idStr) {
|
if (id) {
|
||||||
await dispatch(
|
await dispatch(
|
||||||
update({ id: idStr, data: data as unknown as Partial<TourPage> }),
|
update({ id, data: data as unknown as Partial<TourPage> }),
|
||||||
);
|
);
|
||||||
await router.push('/tour_pages/tour_pages-list');
|
await router.push('/tour_pages/tour_pages-list');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
|
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
|
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
@ -19,8 +19,9 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
|
|||||||
import { SwitchField } from '../../components/SwitchField';
|
import { SwitchField } from '../../components/SwitchField';
|
||||||
|
|
||||||
import { update, fetch } from '../../stores/users/usersSlice';
|
import { update, fetch } from '../../stores/users/usersSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
import { useAppDispatch } from '../../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||||
|
|
||||||
const initVals = {
|
const initVals = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -28,46 +29,27 @@ const initVals = {
|
|||||||
phoneNumber: '',
|
phoneNumber: '',
|
||||||
email: '',
|
email: '',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
avatar: [],
|
avatar: [] as Array<{ id: string; publicUrl: string }>,
|
||||||
app_role: null,
|
app_role: null as { id: string; name: string } | null,
|
||||||
custom_permissions: [],
|
custom_permissions: [] as Array<{ id: string; name: string }>,
|
||||||
password: '',
|
password: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditUsersPage = () => {
|
const EditUsersPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
const { users } = useAppSelector((state) => state.users);
|
const { values: initialValues, id } = useEditPageSync({
|
||||||
|
entitySelector: (state) => state.users.users,
|
||||||
const { id } = router.query;
|
fetchAction: fetch,
|
||||||
|
initialValues: initVals,
|
||||||
// Fetch user data
|
});
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
dispatch(fetch({ id: id as string }));
|
|
||||||
}
|
|
||||||
}, [id, dispatch]);
|
|
||||||
|
|
||||||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof users === 'object' && users && !Array.isArray(users)) {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
Object.keys(initVals).forEach((el) => {
|
|
||||||
if (el in users) {
|
|
||||||
(newInitialVal as Record<string, unknown>)[el] = (
|
|
||||||
users as Record<string, unknown>
|
|
||||||
)[el];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [users]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data: typeof initVals) => {
|
const handleSubmit = async (data: typeof initVals) => {
|
||||||
await dispatch(update({ id: id as string, data }));
|
if (id) {
|
||||||
await router.push('/users/users-list');
|
await dispatch(update({ id, data }));
|
||||||
|
await router.push('/users/users-list');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -242,3 +242,133 @@ export function buildElementDefaultsMap(
|
|||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Grouped Props for ElementEditorPanel
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor layout props: panel position and collapse state
|
||||||
|
*/
|
||||||
|
export interface EditorLayoutProps {
|
||||||
|
elementEditorRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
onDragStart: (event: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor state props: tabs and title
|
||||||
|
*/
|
||||||
|
export interface EditorStateProps {
|
||||||
|
title: string;
|
||||||
|
activeTab: 'general' | 'css' | 'effects';
|
||||||
|
onTabChange: (tab: 'general' | 'css' | 'effects') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selected element props
|
||||||
|
*/
|
||||||
|
export interface EditorElementProps {
|
||||||
|
selectedElement: CanvasElement | null;
|
||||||
|
selectedMenuItem:
|
||||||
|
| 'none'
|
||||||
|
| 'background_image'
|
||||||
|
| 'background_video'
|
||||||
|
| 'background_audio'
|
||||||
|
| 'create_transition';
|
||||||
|
onRemoveElement: () => void;
|
||||||
|
onUpdateElement: (patch: Partial<CanvasElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background settings props
|
||||||
|
*/
|
||||||
|
export interface EditorBackgroundProps {
|
||||||
|
backgroundImageUrl: string;
|
||||||
|
backgroundVideoUrl: string;
|
||||||
|
backgroundAudioUrl: string;
|
||||||
|
onBackgroundImageChange: (value: string) => void;
|
||||||
|
onBackgroundVideoChange: (value: string) => void;
|
||||||
|
onBackgroundAudioChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition creation props
|
||||||
|
*/
|
||||||
|
export interface EditorTransitionProps {
|
||||||
|
newTransitionName: string;
|
||||||
|
newTransitionVideoUrl: string;
|
||||||
|
newTransitionSupportsReverse: boolean;
|
||||||
|
isCreatingTransition: boolean;
|
||||||
|
onNewTransitionNameChange: (value: string) => void;
|
||||||
|
onNewTransitionVideoUrlChange: (value: string) => void;
|
||||||
|
onNewTransitionSupportsReverseChange: (value: boolean) => void;
|
||||||
|
onCreateTransition: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration notes props
|
||||||
|
*/
|
||||||
|
export interface EditorDurationNotesProps {
|
||||||
|
backgroundVideoDurationNote: string;
|
||||||
|
backgroundAudioDurationNote: string;
|
||||||
|
newTransitionDurationNote: string;
|
||||||
|
selectedMediaDurationNote: string;
|
||||||
|
selectedTransitionDurationNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset options for dropdowns
|
||||||
|
*/
|
||||||
|
export interface EditorAssetOptionsProps {
|
||||||
|
backgroundImageAssetOptions: AssetOption[];
|
||||||
|
videoAssetOptions: AssetOption[];
|
||||||
|
audioAssetOptions: AssetOption[];
|
||||||
|
transitionVideoAssetOptions: AssetOption[];
|
||||||
|
iconAssetOptions: AssetOption[];
|
||||||
|
imageAssetOptions: AssetOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation settings props
|
||||||
|
*/
|
||||||
|
export interface EditorNavigationProps {
|
||||||
|
allowedNavigationTypes: Array<'navigation_next' | 'navigation_prev'>;
|
||||||
|
pages: Array<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
sort_order?: number;
|
||||||
|
}>;
|
||||||
|
activePageId: string;
|
||||||
|
onPreviewTransition: (direction: 'forward' | 'back') => void;
|
||||||
|
normalizeNavigationType: (
|
||||||
|
element: CanvasElement,
|
||||||
|
nextType: 'navigation_next' | 'navigation_prev',
|
||||||
|
) => CanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery/Carousel operations props
|
||||||
|
*/
|
||||||
|
export interface EditorCollectionOpsProps {
|
||||||
|
galleryCards: {
|
||||||
|
add: () => void;
|
||||||
|
update: (cardId: string, patch: Partial<GalleryCard>) => void;
|
||||||
|
remove: (cardId: string) => void;
|
||||||
|
};
|
||||||
|
carouselSlides: {
|
||||||
|
add: () => void;
|
||||||
|
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
|
||||||
|
remove: (slideId: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media utilities props
|
||||||
|
*/
|
||||||
|
export interface EditorMediaUtilsProps {
|
||||||
|
getDuration: (url: string) => number | undefined;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user