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
|
||||
│ │ │
|
||||
/constructor?projectId= /p/[slug]/stage /p/[slug]
|
||||
/constructor?projectId= /p/[projectSlug]/stage /p/[projectSlug]
|
||||
(editing mode) (preview) (public access)
|
||||
│ │ │
|
||||
└── Save to Stage ──────►└── Publish ─────────────►│
|
||||
@ -225,7 +225,7 @@ EMAIL_PASS=...
|
||||
### Frontend (`frontend/.env.local`)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_BACK_API=http://localhost:8080
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
@ -111,7 +111,9 @@ backend/src/
|
||||
├── middlewares/
|
||||
│ ├── check-permissions.js # RBAC permission checking
|
||||
│ ├── 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/
|
||||
│ ├── router.factory.js # Generate CRUD routes
|
||||
@ -302,8 +304,13 @@ Example: `CREATE_PROJECTS`, `READ_TOUR_PAGES`, `UPDATE_ASSETS`
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| Administrator | Full access to all features |
|
||||
| Analytics Viewer | Read-only access for analytics |
|
||||
| Administrator | Full access to all features (user/role/permission management) |
|
||||
| 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
|
||||
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Access_logsDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -53,6 +49,30 @@ class Access_logsDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -63,136 +83,6 @@ class Access_logsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Asset_variantsDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -50,6 +46,27 @@ class Asset_variantsDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -60,112 +77,6 @@ class Asset_variantsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class AssetsDBApi extends GenericDBApi {
|
||||
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() {
|
||||
return [
|
||||
{
|
||||
@ -83,112 +83,6 @@ class AssetsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -50,8 +50,83 @@ class GenericDBApi {
|
||||
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) {
|
||||
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 = {}) {
|
||||
|
||||
@ -37,19 +37,29 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
||||
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 {
|
||||
id: data.id || undefined,
|
||||
element_type: data.element_type ?? null,
|
||||
name: data.name ?? null,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
default_settings_json:
|
||||
data.default_settings_json === null ||
|
||||
data.default_settings_json === undefined
|
||||
? null
|
||||
: typeof data.default_settings_json === 'string'
|
||||
? data.default_settings_json
|
||||
: JSON.stringify(data.default_settings_json),
|
||||
element_type: { default: null },
|
||||
name: { default: null },
|
||||
sort_order: { default: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
static getFieldMapping(data) {
|
||||
// Apply base class transformations (JSON fields, defaults, transformers)
|
||||
const mapped = super.getFieldMapping(data);
|
||||
|
||||
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 assert = require('assert');
|
||||
const services = require('../../services/file');
|
||||
const services = require('../../services/file/');
|
||||
|
||||
module.exports = class FileDBApi {
|
||||
static async replaceRelationFiles(relation, rawFiles, options) {
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Presigned_url_requestsDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -53,6 +49,30 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -65,136 +85,6 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -61,21 +61,39 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
||||
return 'name';
|
||||
}
|
||||
|
||||
static getFieldMapping(data) {
|
||||
// Declarative field configuration using base class patterns
|
||||
static get JSON_FIELDS() {
|
||||
return ['settings_json'];
|
||||
}
|
||||
|
||||
static get FIELD_DEFAULTS() {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
element_type: data.element_type ?? null,
|
||||
name: data.name ?? null,
|
||||
sort_order: data.sort_order ?? 0,
|
||||
settings_json:
|
||||
data.settings_json === null || data.settings_json === undefined
|
||||
? null
|
||||
: typeof data.settings_json === 'string'
|
||||
? data.settings_json
|
||||
: JSON.stringify(data.settings_json),
|
||||
source_element_id: data.source_element_id ?? null,
|
||||
snapshot_version: data.snapshot_version ?? 1,
|
||||
projectId: data.projectId || data.project || undefined,
|
||||
element_type: { default: null },
|
||||
name: { default: null },
|
||||
sort_order: { default: 0 },
|
||||
source_element_id: { default: null },
|
||||
snapshot_version: { default: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
static getFieldMapping(data) {
|
||||
// Apply base class transformations (JSON fields, defaults, transformers)
|
||||
const mapped = super.getFieldMapping(data);
|
||||
|
||||
// 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 db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Project_membershipsDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -52,6 +48,30 @@ class Project_membershipsDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -61,130 +81,6 @@ class Project_membershipsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Publish_eventsDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -60,6 +56,30 @@ class Publish_eventsDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -76,136 +96,6 @@ class Publish_eventsDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class Pwa_cachesDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -49,6 +45,21 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -60,112 +71,6 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
class RolesDBApi extends GenericDBApi {
|
||||
static get MODEL() {
|
||||
@ -42,6 +38,27 @@ class RolesDBApi extends GenericDBApi {
|
||||
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) {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
@ -49,105 +66,6 @@ class RolesDBApi extends GenericDBApi {
|
||||
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;
|
||||
|
||||
@ -10,6 +10,12 @@ const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
const { logger, requestLogger } = require('./utils/logger');
|
||||
const {
|
||||
uploadLimiter,
|
||||
downloadLimiter,
|
||||
searchLimiter,
|
||||
aiLimiter,
|
||||
} = require('./middlewares/rateLimiter');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const fileRoutes = require('./routes/file');
|
||||
@ -125,8 +131,13 @@ app.use(requestLogger);
|
||||
// Initialize passport JWT auth early (before file routes)
|
||||
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)
|
||||
// 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);
|
||||
|
||||
// Body parser for all other routes
|
||||
@ -227,10 +238,10 @@ app.use(
|
||||
|
||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||
|
||||
app.use('/api/openai', jwtAuth, openaiRoutes);
|
||||
app.use('/api/ai', jwtAuth, openaiRoutes);
|
||||
app.use('/api/openai', jwtAuth, aiLimiter, 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);
|
||||
|
||||
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 EmailSender = require('../services/email');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const {
|
||||
authLimiter: signinLimiter,
|
||||
signupLimiter,
|
||||
passwordResetLimiter,
|
||||
} = require('../middlewares/rateLimiter');
|
||||
|
||||
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) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const bodyParser = require('body-parser');
|
||||
const services = require('../services/file');
|
||||
const services = require('../services/file/');
|
||||
const router = express.Router();
|
||||
|
||||
// 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 = {
|
||||
...require('./errors'),
|
||||
...require('./logger'),
|
||||
...require('./circuit-breaker'),
|
||||
...require('./events'),
|
||||
envValidation: require('./env-validation'),
|
||||
};
|
||||
|
||||
@ -59,9 +59,10 @@ frontend/src/
|
||||
│ ├── register.tsx # Registration page
|
||||
│ ├── dashboard.tsx # Main dashboard
|
||||
│ ├── constructor.tsx # Tour builder/editor (drag-drop, elements)
|
||||
│ ├── runtime.tsx # Tour playback viewer
|
||||
│ ├── 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
|
||||
│ ├── tour_pages/ # Tour page management
|
||||
│ ├── assets/ # Asset library
|
||||
@ -116,10 +117,10 @@ frontend/src/
|
||||
│ ├── mediaDuration.ts # Video/audio duration
|
||||
│ ├── assetUrl.ts # CDN URL resolution
|
||||
│ ├── extractPageLinks.ts # Extract navigation links from pages
|
||||
│ ├── StorageManager.ts # Cache API storage for assets
|
||||
│ ├── parseJson.ts # Safe JSON parsing
|
||||
│ ├── logger.ts # Client-side logging
|
||||
│ ├── offline/ # Offline utilities
|
||||
│ │ └── StorageManager.ts # Cache API storage for assets
|
||||
│ └── offlineDb/ # IndexedDB (Dexie) setup
|
||||
│
|
||||
├── layouts/ # Page layouts
|
||||
@ -154,9 +155,9 @@ Visual tour builder with:
|
||||
- **Always shows `dev` environment content**
|
||||
- "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
|
||||
- Video transitions between pages
|
||||
- Forward/reverse playback
|
||||
@ -164,11 +165,11 @@ Tour playback viewer with:
|
||||
- Keyboard/touch navigation
|
||||
- Asset preloading with S3 presigned URLs
|
||||
|
||||
### Public Tours (`/p/[slug]`)
|
||||
### Public Tours (`/p/[projectSlug]`)
|
||||
|
||||
PWA-enabled public tour access:
|
||||
- **`/p/[slug]`** - Shows `production` environment (published content)
|
||||
- **`/p/[slug]/stage`** - Shows `stage` environment (preview)
|
||||
- **`/p/[projectSlug]`** - Shows `production` environment (published content)
|
||||
- **`/p/[projectSlug]/stage`** - Shows `stage` environment (preview)
|
||||
- Offline support via Service Worker
|
||||
- Asset caching in Cache API and IndexedDB
|
||||
- Direct S3 downloads via presigned URLs
|
||||
@ -215,7 +216,9 @@ dispatch(create({ data: newProject }));
|
||||
| `useOfflineMode` | Detect offline/online status |
|
||||
| `usePWAPreload` | Preload assets for offline |
|
||||
| `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
|
||||
|
||||
@ -262,8 +265,8 @@ Tour pages have a three-tier environment model:
|
||||
| Environment | Route | Description |
|
||||
|-------------|-------|-------------|
|
||||
| `dev` | `/constructor?projectId=` | Editing/draft content |
|
||||
| `stage` | `/p/[slug]/stage` | Pre-production review |
|
||||
| `production` | `/p/[slug]` | Published public content |
|
||||
| `stage` | `/p/[projectSlug]/stage` | Pre-production review |
|
||||
| `production` | `/p/[projectSlug]` | Published public content |
|
||||
|
||||
**Publishing flow:** `dev` → `stage` → `production`
|
||||
|
||||
@ -278,8 +281,8 @@ The `X-Runtime-Environment` header tells the backend which environment to query.
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# API URL (defaults to localhost:8080)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
# Backend API URL (defaults to localhost:8080)
|
||||
NEXT_PUBLIC_BACK_API=http://localhost:8080
|
||||
|
||||
# Frontend port (optional, default 3000)
|
||||
FRONT_PORT=3000
|
||||
|
||||
@ -21,10 +21,10 @@ const nextConfig = {
|
||||
position: 'bottom-left',
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Access Logs Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/access_logs/access_logsSlice';
|
||||
import { loadColumns } from './configureAccess_logsCols';
|
||||
import type { AccessLog } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableAccess_logsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableAccess_logs: React.FC<TableAccess_logsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableAccess_logs = createTableComponent<AccessLog>({
|
||||
entityName: 'access_logs',
|
||||
sliceSelector: (state) => state.access_logs,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableAccess_logs;
|
||||
|
||||
@ -1,159 +1,52 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const ACCESS_LOGS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'access_logs',
|
||||
columns: ACCESS_LOGS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Asset Variants Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/asset_variants/asset_variantsSlice';
|
||||
import { loadColumns } from './configureAsset_variantsCols';
|
||||
import type { AssetVariant } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableAsset_variantsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableAsset_variants: React.FC<TableAsset_variantsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableAsset_variants = createTableComponent<AssetVariant>({
|
||||
entityName: 'asset_variants',
|
||||
sliceSelector: (state) => state.asset_variants,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableAsset_variants;
|
||||
|
||||
@ -1,143 +1,40 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const ASSET_VARIANTS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'asset_variants',
|
||||
columns: ASSET_VARIANTS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Assets Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/assets/assetsSlice';
|
||||
import { loadColumns } from './configureAssetsCols';
|
||||
import type { Asset } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableAssetsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableAssets: React.FC<TableAssetsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableAssets = createTableComponent<Asset>({
|
||||
entityName: 'assets',
|
||||
sliceSelector: (state) => state.assets,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableAssets;
|
||||
|
||||
@ -1,260 +1,74 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const ASSETS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'assets',
|
||||
columns: ASSETS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -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 { ColorButtonKey } from '../interfaces';
|
||||
import BaseButton from './BaseButton';
|
||||
import ImagesUploader from './Uploaders/ImagesUploader';
|
||||
import FileUploader from './Uploaders/UploadService';
|
||||
import { mdiReload } from '@mdi/js';
|
||||
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 @@
|
||||
/**
|
||||
* Permissions Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/permissions/permissionsSlice';
|
||||
import { loadColumns } from './configurePermissionsCols';
|
||||
import type { PermissionEntity } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TablePermissionsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TablePermissions: React.FC<TablePermissionsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TablePermissions = createTableComponent<PermissionEntity>({
|
||||
entityName: 'permissions',
|
||||
sliceSelector: (state) => state.permissions,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TablePermissions;
|
||||
|
||||
@ -1,68 +1,14 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PERMISSIONS_COLUMNS: ColumnMetadata[] = [
|
||||
{ field: 'name', headerName: 'Name', type: 'text', editable: true },
|
||||
{ field: 'actions', headerName: '', type: 'actions' },
|
||||
];
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
export const loadColumns = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'permissions',
|
||||
columns: PERMISSIONS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Presigned URL Requests Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,34 +8,16 @@ import {
|
||||
} from '../../stores/presigned_url_requests/presigned_url_requestsSlice';
|
||||
import { loadColumns } from './configurePresigned_url_requestsCols';
|
||||
import type { PresignedUrlRequest } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TablePresigned_url_requestsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TablePresigned_url_requests: React.FC<
|
||||
TablePresigned_url_requestsProps
|
||||
> = ({ 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TablePresigned_url_requests = createTableComponent<PresignedUrlRequest>({
|
||||
entityName: 'presigned_url_requests',
|
||||
sliceSelector: (state) => state.presigned_url_requests,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TablePresigned_url_requests;
|
||||
|
||||
@ -1,190 +1,54 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PRESIGNED_URL_REQUESTS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'presigned_url_requests',
|
||||
columns: PRESIGNED_URL_REQUESTS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Project Audio Tracks Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/project_audio_tracks/project_audio_tracksSlice';
|
||||
import { loadColumns } from './configureProject_audio_tracksCols';
|
||||
import type { ProjectAudioTrack } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableProject_audio_tracksProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableProject_audio_tracks: React.FC<TableProject_audio_tracksProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableProject_audio_tracks = createTableComponent<ProjectAudioTrack>({
|
||||
entityName: 'project_audio_tracks',
|
||||
sliceSelector: (state) => state.project_audio_tracks,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableProject_audio_tracks;
|
||||
|
||||
@ -1,196 +1,49 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PROJECT_AUDIO_TRACKS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'project_audio_tracks',
|
||||
columns: PROJECT_AUDIO_TRACKS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Project Memberships Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/project_memberships/project_membershipsSlice';
|
||||
import { loadColumns } from './configureProject_membershipsCols';
|
||||
import type { ProjectMembership } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableProject_membershipsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableProject_memberships: React.FC<TableProject_membershipsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableProject_memberships = createTableComponent<ProjectMembership>({
|
||||
entityName: 'project_memberships',
|
||||
sliceSelector: (state) => state.project_memberships,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableProject_memberships;
|
||||
|
||||
@ -1,154 +1,51 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PROJECT_MEMBERSHIPS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'project_memberships',
|
||||
columns: PROJECT_MEMBERSHIPS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Projects Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/projects/projectsSlice';
|
||||
import { loadColumns } from './configureProjectsCols';
|
||||
import type { Project } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableProjectsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableProjects: React.FC<TableProjectsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableProjects = createTableComponent<Project>({
|
||||
entityName: 'projects',
|
||||
sliceSelector: (state) => state.projects,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableProjects;
|
||||
|
||||
@ -1,193 +1,64 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PROJECTS_COLUMNS: ColumnMetadata[] = [
|
||||
{ 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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'projects',
|
||||
columns: PROJECTS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Publish Events Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/publish_events/publish_eventsSlice';
|
||||
import { loadColumns } from './configurePublish_eventsCols';
|
||||
import type { PublishEvent } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TablePublish_eventsProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TablePublish_events: React.FC<TablePublish_eventsProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TablePublish_events = createTableComponent<PublishEvent>({
|
||||
entityName: 'publish_events',
|
||||
sliceSelector: (state) => state.publish_events,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TablePublish_events;
|
||||
|
||||
@ -1,242 +1,90 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PUBLISH_EVENTS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'publish_events',
|
||||
columns: PUBLISH_EVENTS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* PWA Caches Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/pwa_caches/pwa_cachesSlice';
|
||||
import { loadColumns } from './configurePwa_cachesCols';
|
||||
import type { PwaCache } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TablePwa_cachesProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TablePwa_caches: React.FC<TablePwa_cachesProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TablePwa_caches = createTableComponent<PwaCache>({
|
||||
entityName: 'pwa_caches',
|
||||
sliceSelector: (state) => state.pwa_caches,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TablePwa_caches;
|
||||
|
||||
@ -1,154 +1,56 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const PWA_CACHES_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'pwa_caches',
|
||||
columns: PWA_CACHES_COLUMNS,
|
||||
});
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Roles Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/roles/rolesSlice';
|
||||
import { loadColumns } from './configureRolesCols';
|
||||
import type { Role } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableRolesProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableRoles: React.FC<TableRolesProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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;
|
||||
|
||||
@ -1,89 +1,30 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import DataGridMultiSelect from '../DataGridMultiSelect';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const ROLES_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'roles',
|
||||
columns: ROLES_COLUMNS,
|
||||
});
|
||||
|
||||
@ -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 { useAppSelector } from '../stores/hooks';
|
||||
import { logger } from '../lib/logger';
|
||||
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
||||
import {
|
||||
toRoutePath,
|
||||
compareRoutes,
|
||||
getProjectId,
|
||||
getRows,
|
||||
} from '../lib/tourFlowHelpers';
|
||||
|
||||
type TourPage = {
|
||||
id: string;
|
||||
@ -54,79 +61,6 @@ type ListEntry = {
|
||||
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 router = useRouter();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Tour Pages Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/tour_pages/tour_pagesSlice';
|
||||
import { loadColumns } from './configureTour_pagesCols';
|
||||
import type { TourPage } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableTour_pagesProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableTour_pages: React.FC<TableTour_pagesProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableTour_pages = createTableComponent<TourPage>({
|
||||
entityName: 'tour_pages',
|
||||
sliceSelector: (state) => state.tour_pages,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableTour_pages;
|
||||
|
||||
@ -1,215 +1,76 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const TOUR_PAGES_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'tour_pages',
|
||||
columns: TOUR_PAGES_COLUMNS,
|
||||
});
|
||||
|
||||
@ -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}`;
|
||||
|
||||
console.log(
|
||||
'process.env.NODE_ENV in uploadToServer function',
|
||||
process.env.NODE_ENV,
|
||||
);
|
||||
console.log('baseURLApi in uploadToServer function', baseURLApi);
|
||||
// Debug logging removed for production builds
|
||||
|
||||
return `${baseURLApi}/file/download?privateUrl=${privateUrl}`;
|
||||
}
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
/**
|
||||
* Users Table Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import GenericTable from '../Generic/GenericTable';
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
@ -13,36 +8,16 @@ import {
|
||||
} from '../../stores/users/usersSlice';
|
||||
import { loadColumns } from './configureUsersCols';
|
||||
import type { User } from '../../types/entities';
|
||||
import type { RootState } from '../../stores/store';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
interface TableUsersProps {
|
||||
filterItems: FilterItem[];
|
||||
setFilterItems: (items: FilterItem[]) => void;
|
||||
filters: Filter[];
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
const TableUsers: React.FC<TableUsersProps> = ({
|
||||
filterItems,
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const TableUsers = createTableComponent<User>({
|
||||
entityName: 'users',
|
||||
sliceSelector: (state) => state.users,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
|
||||
export default TableUsers;
|
||||
|
||||
@ -1,181 +1,63 @@
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import { GridColDef, GridRowParams } from '@mui/x-data-grid';
|
||||
import { GridRenderCellParams } from '@mui/x-data-grid';
|
||||
import ImageField from '../ImageField';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import DataGridMultiSelect from '../DataGridMultiSelect';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import {
|
||||
createColumnLoader,
|
||||
ColumnMetadata,
|
||||
} from '../DataGrid/configBuilderFactory';
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { logger } from '../../lib/logger';
|
||||
const USERS_COLUMNS: ColumnMetadata[] = [
|
||||
{
|
||||
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 = async (
|
||||
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>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
export const loadColumns = createColumnLoader({
|
||||
entityName: 'users',
|
||||
columns: USERS_COLUMNS,
|
||||
});
|
||||
|
||||
@ -32,9 +32,11 @@ export type FormFieldType =
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'selectMany'
|
||||
| 'enumSelect'
|
||||
| 'switch'
|
||||
| 'image'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'password'
|
||||
| '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':
|
||||
return (
|
||||
<Field
|
||||
@ -293,6 +306,15 @@ function renderField<T>(
|
||||
/>
|
||||
);
|
||||
|
||||
case 'datetime':
|
||||
return (
|
||||
<Field
|
||||
name={name}
|
||||
type='datetime-local'
|
||||
placeholder={placeholder || field.label}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
if (CustomComponent) {
|
||||
return (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import dayjs from 'dayjs';
|
||||
import _ from 'lodash';
|
||||
|
||||
export default {
|
||||
const dataFormatter = {
|
||||
filesFormatter(arr) {
|
||||
if (!arr || !arr.length) return [];
|
||||
return arr.map((item) => item);
|
||||
@ -172,3 +172,5 @@ export default {
|
||||
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
|
||||
* Cleaned up version with consolidated useEffect hooks
|
||||
*/
|
||||
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
|
||||
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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { AccessLog } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -41,36 +41,24 @@ const initVals = {
|
||||
const EditAccess_logsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const { access_logs } = useAppSelector((state) => state.access_logs);
|
||||
const { id } = router.query;
|
||||
|
||||
// Fetch entity 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 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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) => state.access_logs.access_logs,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
await dispatch(
|
||||
update({ id: id as string, data: data as unknown as Partial<AccessLog> }),
|
||||
);
|
||||
await router.push('/access_logs/access_logs-list');
|
||||
if (id) {
|
||||
await dispatch(
|
||||
update({ id, data: data as unknown as Partial<AccessLog> }),
|
||||
);
|
||||
await router.push('/access_logs/access_logs-list');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -153,7 +141,7 @@ const EditAccess_logsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
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 Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -21,8 +16,9 @@ import BaseButton from '../../components/BaseButton';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
|
||||
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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { AssetVariant } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -37,46 +33,17 @@ const initVals = {
|
||||
const EditAsset_variantsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const assetVariantsState = useAppSelector((state) => state.asset_variants);
|
||||
const asset_variants = assetVariantsState.asset_variants as
|
||||
| AssetVariant
|
||||
| AssetVariant[]
|
||||
| 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 { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.asset_variants.asset_variants,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (idStr) {
|
||||
if (id) {
|
||||
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');
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -20,8 +20,9 @@ import { SelectField } from '../../components/SelectField';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
|
||||
import { update, fetch } from '../../stores/assets/assetsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { Asset } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -45,42 +46,20 @@ const initVals = {
|
||||
const EditAssetsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const assetsState = useAppSelector((state) => state.assets);
|
||||
const assets = assetsState.assets as Asset | Asset[] | undefined;
|
||||
const asset = Array.isArray(assets) ? assets[0] : assets;
|
||||
|
||||
const { id } = router.query;
|
||||
const idStr = Array.isArray(id) ? id[0] : id;
|
||||
|
||||
// Fetch asset data
|
||||
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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) => state.assets.assets,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (idStr) {
|
||||
await dispatch(
|
||||
update({ id: idStr, data: data as unknown as Partial<Asset> }),
|
||||
);
|
||||
if (id) {
|
||||
await dispatch(update({ id, data: data as unknown as Partial<Asset> }));
|
||||
await router.push('/assets/assets-list');
|
||||
}
|
||||
};
|
||||
@ -213,7 +192,7 @@ const EditAssetsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
deleted_at_time: date,
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
import type { ReactElement } from 'react';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
@ -14,102 +13,92 @@ import { hasPermission } from '../helpers/userPermissions';
|
||||
import { fetchWidgets } from '../stores/roles/rolesSlice';
|
||||
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||
import {
|
||||
useDashboardCounts,
|
||||
EntityCountValue,
|
||||
} from '../hooks/useDashboardCounts';
|
||||
|
||||
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 dispatch = useAppDispatch();
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||
|
||||
const loadingMessage = 'Loading...';
|
||||
|
||||
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);
|
||||
// Use the centralized dashboard counts hook
|
||||
const { getCount, getVisibleEntities } = useDashboardCounts(currentUser);
|
||||
|
||||
const [widgetsRole, setWidgetsRole] = React.useState({
|
||||
role: { value: '', label: '' },
|
||||
});
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
|
||||
|
||||
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
|
||||
rolesWidgets: Array<{ id: string; [key: string]: unknown }>;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
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) {
|
||||
async function getWidgets(roleId: string) {
|
||||
await dispatch(fetchWidgets(roleId));
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
loadData().then();
|
||||
setWidgetsRole({
|
||||
role: {
|
||||
value: currentUser?.app_role?.id,
|
||||
@ -123,6 +112,9 @@ const Dashboard = () => {
|
||||
getWidgets(widgetsRole?.role?.value || '').then();
|
||||
}, [widgetsRole?.role?.value]);
|
||||
|
||||
// Get entities visible to current user
|
||||
const visibleEntities = getVisibleEntities();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@ -147,7 +139,7 @@ const Dashboard = () => {
|
||||
)}
|
||||
{!!rolesWidgets.length &&
|
||||
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`}
|
||||
</p>
|
||||
)}
|
||||
@ -155,7 +147,7 @@ const Dashboard = () => {
|
||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
|
||||
{(isFetchingQuery || loading) && (
|
||||
<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
|
||||
className={`${iconsColor} animate-spin mr-5`}
|
||||
@ -180,450 +172,24 @@ const Dashboard = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!rolesWidgets.length && <hr className='my-6 ' />}
|
||||
{!!rolesWidgets.length && <hr className='my-6' />}
|
||||
|
||||
<div
|
||||
id='dashboard'
|
||||
className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'
|
||||
>
|
||||
{hasPermission(currentUser, 'READ_USERS') && (
|
||||
<Link href={'/users/users-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'>
|
||||
Users
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
{visibleEntities.map((entity) => (
|
||||
<DashboardCard
|
||||
key={entity.key}
|
||||
href={entity.href}
|
||||
label={entity.label}
|
||||
count={getCount(entity.key)}
|
||||
iconKey={entity.icon}
|
||||
corners={corners}
|
||||
cardsStyle={cardsStyle}
|
||||
iconsColor={iconsColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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 Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -20,8 +15,9 @@ import BaseButtons from '../../components/BaseButtons';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
|
||||
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
@ -30,34 +26,18 @@ const initVals = {
|
||||
const EditPermissionsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const { permissions } = useAppSelector((state) => state.permissions);
|
||||
const { id } = router.query;
|
||||
|
||||
// Fetch entity 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 permissions === 'object' && permissions !== null) {
|
||||
const newInitialVal = { ...initVals };
|
||||
Object.keys(initVals).forEach((key) => {
|
||||
if (key in permissions) {
|
||||
newInitialVal[key] = permissions[key];
|
||||
}
|
||||
});
|
||||
setInitialValues(newInitialVal);
|
||||
}
|
||||
}, [permissions]);
|
||||
const { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.permissions.permissions,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
await dispatch(update({ id: id as string, data }));
|
||||
await router.push('/permissions/permissions-list');
|
||||
if (id) {
|
||||
await dispatch(update({ id, data }));
|
||||
await router.push('/permissions/permissions-list');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Edit Presigned URL Requests Page
|
||||
* Cleaned up version with consolidated useEffect hooks
|
||||
*/
|
||||
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -27,8 +26,9 @@ import {
|
||||
update,
|
||||
fetch,
|
||||
} 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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { PresignedUrlRequest } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -46,42 +46,17 @@ const initVals = {
|
||||
const EditPresigned_url_requestsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const presignedState = useAppSelector(
|
||||
(state) => state.presigned_url_requests,
|
||||
);
|
||||
const presigned_url_requests = presignedState.presigned_url_requests as
|
||||
| PresignedUrlRequest
|
||||
| PresignedUrlRequest[]
|
||||
| undefined;
|
||||
const presignedRequest = Array.isArray(presigned_url_requests)
|
||||
? presigned_url_requests[0]
|
||||
: 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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) =>
|
||||
state.presigned_url_requests.presigned_url_requests,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (id) {
|
||||
@ -183,7 +158,7 @@ const EditPresigned_url_requestsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
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 Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -25,8 +20,9 @@ import {
|
||||
update,
|
||||
fetch,
|
||||
} 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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { ProjectAudioTrack } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -45,43 +41,12 @@ const initVals = {
|
||||
const EditProject_audio_tracksPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const project_audio_tracksState = useAppSelector(
|
||||
(state) => state.project_audio_tracks,
|
||||
);
|
||||
const project_audio_tracks =
|
||||
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 { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.project_audio_tracks.project_audio_tracks,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (id) {
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Edit Project Memberships Page
|
||||
* Cleaned up version with consolidated useEffect hooks
|
||||
*/
|
||||
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -28,8 +27,9 @@ import {
|
||||
update,
|
||||
fetch,
|
||||
} from '../../stores/project_memberships/project_membershipsSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { ProjectMembership } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -44,42 +44,16 @@ const initVals = {
|
||||
const EditProject_membershipsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const project_membershipsState = useAppSelector(
|
||||
(state) => state.project_memberships,
|
||||
);
|
||||
const project_memberships = project_membershipsState.project_memberships as
|
||||
| ProjectMembership
|
||||
| ProjectMembership[]
|
||||
| undefined;
|
||||
const item = Array.isArray(project_memberships)
|
||||
? 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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) => state.project_memberships.project_memberships,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (id) {
|
||||
@ -163,7 +137,7 @@ const EditProject_membershipsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
invited_at: date,
|
||||
})
|
||||
@ -185,7 +159,7 @@ const EditProject_membershipsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
accepted_at: date,
|
||||
})
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Edit Publish Events Page
|
||||
* Cleaned up version with consolidated useEffect hooks
|
||||
*/
|
||||
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -24,8 +23,9 @@ import BaseButton from '../../components/BaseButton';
|
||||
import { SelectField } from '../../components/SelectField';
|
||||
|
||||
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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { PublishEvent } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -45,40 +45,16 @@ const initVals = {
|
||||
const EditPublish_eventsPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const publish_eventsState = useAppSelector((state) => state.publish_events);
|
||||
const publish_events = publish_eventsState.publish_events as
|
||||
| PublishEvent
|
||||
| PublishEvent[]
|
||||
| undefined;
|
||||
const item = Array.isArray(publish_events)
|
||||
? publish_events[0]
|
||||
: publish_events;
|
||||
|
||||
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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) => state.publish_events.publish_events,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (id) {
|
||||
@ -169,7 +145,7 @@ const EditPublish_eventsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
started_at: date,
|
||||
})
|
||||
@ -191,7 +167,7 @@ const EditPublish_eventsPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
finished_at: date,
|
||||
})
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* Edit PWA Caches Page
|
||||
* Cleaned up version with consolidated useEffect hooks
|
||||
*/
|
||||
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
@ -25,8 +24,9 @@ import { SelectField } from '../../components/SelectField';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
|
||||
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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
|
||||
const initVals = {
|
||||
project: null,
|
||||
@ -41,34 +41,22 @@ const initVals = {
|
||||
const EditPwa_cachesPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const { pwa_caches } = useAppSelector((state) => state.pwa_caches);
|
||||
const { id } = router.query;
|
||||
|
||||
// Fetch entity 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 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 {
|
||||
values: initialValues,
|
||||
setValues,
|
||||
id,
|
||||
} = useEditPageSync({
|
||||
entitySelector: (state) => state.pwa_caches.pwa_caches,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
await dispatch(update({ id: id as string, data }));
|
||||
await router.push('/pwa_caches/pwa_caches-list');
|
||||
if (id) {
|
||||
await dispatch(update({ id, data }));
|
||||
await router.push('/pwa_caches/pwa_caches-list');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -143,7 +131,7 @@ const EditPwa_cachesPage = () => {
|
||||
: null
|
||||
}
|
||||
onChange={(date: Date | null) =>
|
||||
setInitialValues({
|
||||
setValues({
|
||||
...initialValues,
|
||||
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 Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -23,43 +18,28 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { update, fetch } from '../../stores/roles/rolesSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
permissions: [],
|
||||
permissions: [] as Array<{ id: string; name: string }>,
|
||||
};
|
||||
|
||||
const EditRolesPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const { roles } = useAppSelector((state) => state.roles);
|
||||
const { id } = router.query;
|
||||
|
||||
// Fetch entity 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 roles === 'object' && roles !== null) {
|
||||
const newInitialVal = { ...initVals };
|
||||
Object.keys(initVals).forEach((key) => {
|
||||
if (key in roles) {
|
||||
newInitialVal[key] = roles[key];
|
||||
}
|
||||
});
|
||||
setInitialValues(newInitialVal);
|
||||
}
|
||||
}, [roles]);
|
||||
const { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.roles.roles,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
await dispatch(update({ id: id as string, data }));
|
||||
await router.push('/roles/roles-list');
|
||||
if (id) {
|
||||
await dispatch(update({ id, data }));
|
||||
await router.push('/roles/roles-list');
|
||||
}
|
||||
};
|
||||
|
||||
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 Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -22,8 +17,9 @@ import { SelectField } from '../../components/SelectField';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
|
||||
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 { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import type { TourPage } from '../../types/entities';
|
||||
|
||||
const initVals = {
|
||||
@ -44,44 +40,17 @@ const initVals = {
|
||||
const EditTour_pagesPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const tourPagesState = useAppSelector((state) => state.tour_pages);
|
||||
const tour_pages = tourPagesState.tour_pages as
|
||||
| TourPage
|
||||
| TourPage[]
|
||||
| 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 { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.tour_pages.tour_pages,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (idStr) {
|
||||
if (id) {
|
||||
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');
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -19,8 +19,9 @@ import { SelectFieldMany } from '../../components/SelectFieldMany';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
|
||||
import { update, fetch } from '../../stores/users/usersSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
|
||||
const initVals = {
|
||||
firstName: '',
|
||||
@ -28,46 +29,27 @@ const initVals = {
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
disabled: false,
|
||||
avatar: [],
|
||||
app_role: null,
|
||||
custom_permissions: [],
|
||||
avatar: [] as Array<{ id: string; publicUrl: string }>,
|
||||
app_role: null as { id: string; name: string } | null,
|
||||
custom_permissions: [] as Array<{ id: string; name: string }>,
|
||||
password: '',
|
||||
};
|
||||
|
||||
const EditUsersPage = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const [initialValues, setInitialValues] = useState(initVals);
|
||||
|
||||
const { users } = useAppSelector((state) => state.users);
|
||||
|
||||
const { id } = router.query;
|
||||
|
||||
// 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 { values: initialValues, id } = useEditPageSync({
|
||||
entitySelector: (state) => state.users.users,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
await dispatch(update({ id: id as string, data }));
|
||||
await router.push('/users/users-list');
|
||||
if (id) {
|
||||
await dispatch(update({ id, data }));
|
||||
await router.push('/users/users-list');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -242,3 +242,133 @@ export function buildElementDefaultsMap(
|
||||
|
||||
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