1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-02-19 13:54:25 +01:00

API+ interface to CRUD plan categories

This commit is contained in:
Sylvain 2021-06-08 16:32:19 +02:00
parent 3f044513e9
commit e1e446ab3f
22 changed files with 477 additions and 8 deletions

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
# API Controller for resources of type PlanCategory
# PlanCategory are used to sort plans
class API::PlanCategoriesController < API::ApiController
before_action :authenticate_user!
before_action :set_category, only: %i[show update destroy]
def index
authorize PlanCategory
@categories = PlanCategory.all
end
def show; end
def create
authorize PlanCategory
@category = PlanCategory.new(plan_category_params)
if @category.save
render :show, status: :created, location: @category
else
render json: @category.errors, status: :unprocessable_entity
end
end
def update
authorize @category
if @category.update(plan_category_params)
render :show, status: :ok
else
render json: @category.errors, status: :unprocessable_entity
end
end
def destroy
authorize @category
@category.destroy
head :no_content
end
private
def set_category
@category = PlanCategory.find(params[:id])
end
def plan_category_params
params.require(:plan_category).permit(:name, :weight)
end
end

View File

@ -0,0 +1,26 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { PlanCategory } from '../models/plan-category';
export default class PlanCategoryAPI {
static async index (): Promise<Array<PlanCategory>> {
const res: AxiosResponse<Array<PlanCategory>> = await apiClient.get('/api/plan_categories');
return res?.data;
}
static async create (category: PlanCategory): Promise<PlanCategory> {
const res: AxiosResponse<PlanCategory> = await apiClient.post('/api/plan_categories', { plan_category: category });
return res?.data;
}
static async update (category: PlanCategory): Promise<PlanCategory> {
const res: AxiosResponse<PlanCategory> = await apiClient.patch(`/api/plan_categories/${category.id}`, { plan_category: category });
return res?.data;
}
static async destroy (categoryId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/plan_categories/${categoryId}`);
return res?.data;
}
}

View File

@ -1,4 +1,4 @@
import React, { ReactNode, BaseSyntheticEvent } from 'react';
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
import Modal from 'react-modal';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
@ -24,7 +24,8 @@ interface FabModalProps {
width?: ModalSize,
customFooter?: ReactNode,
onConfirm?: (event: BaseSyntheticEvent) => void,
preventConfirm?: boolean
preventConfirm?: boolean,
onCreation?: () => void,
}
// initial request to the API
@ -33,9 +34,15 @@ const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
/**
* This component is a template for a modal dialog that wraps the application style
*/
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm, onCreation }) => {
const { t } = useTranslation('shared');
useEffect(() => {
if (typeof onCreation === 'function' && isOpen) {
onCreation();
}
}, [isOpen]);
// the theme's logo, for back backgrounds
const blackLogo = blackLogoFile.read();

View File

@ -1,11 +1,11 @@
import React from 'react';
import React, { BaseSyntheticEvent } from 'react';
interface LabelledInputProps {
id: string,
type: string,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
label: string,
value: any,
onChange: (value: any) => void
onChange: (event: BaseSyntheticEvent) => void
}
/**

View File

@ -0,0 +1,228 @@
import React, { BaseSyntheticEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PlanCategoryAPI from '../../api/plan-category';
import { PlanCategory } from '../../models/plan-category';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { LabelledInput } from '../base/labelled-input';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
declare var Application: IApplication;
interface PlanCategoriesListProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a list of all plan-categories and offer to manager them by deleting, modifying
* and reordering each plan-categories.
*/
export const PlanCategoriesList: React.FC<PlanCategoriesListProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
// list of all categories
const [categories, setCategories] = useState<Array<PlanCategory>>(null);
// when editing or deleting a category, it will be stored here until the edition is over
const [tempCategory, setTempCategory] = useState<PlanCategory>(null);
// is the creation modal open?
const [creationModal, setCreationModal] = useState<boolean>(false);
// is the edition modal open?
const [editionModal, setEditionModal] = useState<boolean>(false);
// is the deletion modal open?
const [deletionModal, setDeletionModal] = useState<boolean>(false);
// load the categories list on component mount
useEffect(() => {
refreshCategories();
}, []);
/**
* Opens/closes the new plan-category (creation) modal
*/
const toggleCreationModal = (): void => {
setCreationModal(!creationModal);
};
/**
* Opens/closes the edition modal
*/
const toggleEditionModal = (): void => {
setEditionModal(!editionModal);
};
/**
* Opens/closes the deletion modal
*/
const toggleDeletionModal = (): void => {
setDeletionModal(!deletionModal);
};
/**
* Triggered when the edit-category button is pushed.
* Set the provided category to the currently edited category then open the edition modal.
*/
const handleEditCategory = (category: PlanCategory) => {
return () => {
setTempCategory(category);
toggleEditionModal();
};
};
/**
* Triggered when the delete-category button is pushed.
* Set the provided category to the currently deleted category then open the deletion modal.
*/
const handleDeleteCategory = (category: PlanCategory) => {
return () => {
setTempCategory(category);
toggleDeletionModal();
};
};
/**
* The creation has been confirmed by the user.
* Push the new plan-category to the API.
*/
const onCreateConfirmed = (): void => {
PlanCategoryAPI.create(tempCategory).then(() => {
onSuccess(t('app.admin.plan_categories_list.category_created'));
resetTempCategory();
toggleCreationModal();
refreshCategories();
}).catch((error) => {
onError(t('app.admin.plan_categories_list.unable_to_create') + error);
});
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the temporary set plan-category
*/
const onDeleteConfirmed = (): void => {
PlanCategoryAPI.destroy(tempCategory.id).then(() => {
onSuccess(t('app.admin.plan_categories_list.category_deleted'));
refreshCategories();
}).catch((error) => {
onError(t('app.admin.plan_categories_list.unable_to_delete') + error);
});
resetTempCategory();
toggleDeletionModal();
};
/**
* The edit has been confirmed by the user.
* Call the API to trigger the update of the temporary set plan-category
*/
const onEditConfirmed = (): void => {
PlanCategoryAPI.update(tempCategory).then(() => {
onSuccess(t('app.admin.plan_categories_list.category_updated'));
resetTempCategory();
refreshCategories();
toggleEditionModal();
}).catch((error) => {
onError(t('app.admin.plan_categories_list.unable_to_update') + error);
});
};
/**
* Callback triggered when the user is changing the name of the category in the modal dialog.
* We update the name of the temporary set plan-category accordingly.
*/
const onCategoryNameChange = (event: BaseSyntheticEvent) => {
setTempCategory({...tempCategory, name: event.target.value });
};
/**
* Initialize a new plan-category for creation
*/
const initCategoryCreation = () => {
setTempCategory({ name: '', weight: 0 });
};
/**
* Reinitialize the temporary category to prevent ghost data
*/
const resetTempCategory = () => {
setTempCategory(null);
}
/**
* Refresh the list of categories
*/
const refreshCategories = () => {
PlanCategoryAPI.index().then((data) => {
setCategories(data);
}).catch((error) => onError(error));
};
return (
<div className="plan-categories-list">
<FabButton type='button'
icon={<i className='fa fa-plus' />}
className="add-category"
onClick={toggleCreationModal}>
{t('app.admin.plan_categories_list.new_category')}
</FabButton>
<h3>{t('app.admin.plan_categories_list.categories_list')}</h3>
<table className="categories-table">
{categories && categories.map(c =>
<tr key={c.id}>
<td className="category-name">{c.name}</td>
<td className="category-actions">
<FabButton type='button' className="edit-button" icon={<i className="fa fa-edit" />} onClick={handleEditCategory(c)} />
<FabButton type='button' className="delete-button" icon={<i className="fa fa-trash" />} onClick={handleDeleteCategory(c)} />
</td>
</tr>)}
</table>
<FabModal title={t('app.admin.plan_categories_list.new_category')}
isOpen={creationModal}
toggleModal={toggleCreationModal}
closeButton={true}
confirmButton={t('app.admin.plan_categories_list.confirm_create')}
onConfirm={onCreateConfirmed}
onCreation={initCategoryCreation}>
{tempCategory && <LabelledInput id="name"
label={t('app.admin.plan_categories_list.name')}
type="text"
value={tempCategory.name}
onChange={onCategoryNameChange} />}
</FabModal>
<FabModal title={t('app.admin.plan_categories_list.delete_category')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.plan_categories_list.confirm_delete')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.plan_categories_list.delete_confirmation')}</span>
</FabModal>
<FabModal title={t('app.admin.plan_categories_list.edit_category')}
isOpen={editionModal}
toggleModal={toggleEditionModal}
closeButton={true}
confirmButton={t('app.admin.plan_categories_list.confirm_edition')}
onConfirm={onEditConfirmed}>
{tempCategory && <div>
<LabelledInput id="category-name"
type="text"
label={t('app.admin.plan_categories_list.name')}
value={tempCategory.name}
onChange={onCategoryNameChange} />
</div>}
</FabModal>
</div>
)
}
const PlanCategoriesListWrapper: React.FC<PlanCategoriesListProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<PlanCategoriesList onSuccess={onSuccess} onError={onError} />
</Loader>
);
}
Application.Components.component('planCategoriesList', react2angular(PlanCategoriesListWrapper, ['onSuccess', 'onError']));

View File

@ -344,3 +344,20 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
return new PlanController($scope, groups, prices, partners, CSRF, _t);
}
]);
/**
* Controller used the plan-categories administration page.
* This is just a wrapper to integrate the React component in the angular app
*/
Application.Controllers.controller('PlanCategoriesController', ['$scope', 'growl',
function ($scope, growl) {
/* PUBLIC SCOPE */
$scope.onSuccess = function (message) {
growl.success(message);
};
$scope.onError = function (message) {
growl.error(message);
};
}
]);

View File

@ -0,0 +1,5 @@
export interface PlanCategory {
id?: number,
name: string,
weight: number,
}

View File

@ -818,6 +818,16 @@ angular.module('application.router', ['ui.router'])
planPromise: ['Plan', '$stateParams', function (Plan, $stateParams) { return Plan.get({ id: $stateParams.id }).$promise; }]
}
})
// plan categories
.state('app.admin.plan_categories', {
url: '/admin/plan_categories',
views: {
'main@': {
templateUrl: '/admin/plans/categories.html',
controller: 'PlanCategoriesController'
}
}
})
// coupons
.state('app.admin.coupons_new', {

View File

@ -43,5 +43,6 @@
@import "modules/payzen-modal";
@import "modules/stripe-update-card-modal";
@import "modules/payzen-update-card-modal";
@import "modules/plan-categories-list";
@import "app.responsive";

View File

@ -0,0 +1,26 @@
.plan-categories-list {
padding: 1.5em;
.categories-table {
width: 100%;
max-width: 100%;
margin-bottom: 24px;
& > tr > td {
padding: 8px;
line-height: 1.5;
vertical-align: top;
border-top: 1px solid #ddd;
}
.delete-button {
color: #fff;
background-color: #cb1117;
border-color: #c9c9c9;
}
.category-actions {
text-align: right;
}
}
}

View File

@ -0,0 +1,21 @@
<section class="heading b-b">
<div class="row no-gutter">
<div class="col-xs-2 col-sm-2 col-md-1">
<section class="heading-btn">
<a href="#" ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<section class="heading-title">
<h1 translate>{{ 'app.admin.plans_categories.manage_plans_categories' }}</h1>
</section>
</div>
</div>
</section>
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<plan-categories-list on-success="onSuccess" on-error="onError" />
</div>
</div>

View File

@ -5,11 +5,16 @@
<a href="#" ng-click="backPrevLocation($event)"><i class="fas fa-long-arrow-alt-left "></i></a>
</section>
</div>
<div class="col-xs-10 col-sm-10 col-md-8 b-l">
<div class="col-xs-10 col-sm-10 col-md-8 b-l b-r">
<section class="heading-title">
<h1 translate>{{ 'app.admin.pricing.pricing_management' }}</h1>
</section>
</div>
<div class="col-xs-3 col-md-3">
<section class="heading-actions wrapper">
<a class="btn btn-default rounded m-t-sm" ui-sref="app.admin.plan_categories"><i class="fa fa-list"></i></a>
</section>
</div>
</div>
</section>

View File

@ -4,6 +4,7 @@
# Subscribers can also get some Credits for some reservable items
class Plan < ApplicationRecord
belongs_to :group
belongs_to :plan_category
has_many :credits, dependent: :destroy
has_many :training_credits, -> { where(creditable_type: 'Training') }, class_name: 'Credit'

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Allows to sort plans into categories. Plans are sorted by multiple criterion,
# ordered as follow:
# - group
# - plan_category
# - plan
class PlanCategory < ApplicationRecord
has_many :plan, dependent: :nullify
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Check the access policies for API::PlanCategoriesController
class PlanCategoryPolicy < ApplicationPolicy
%w[index show create update destroy].each do |action|
define_method "#{action}?" do
user.admin?
end
end
end

View File

@ -374,3 +374,12 @@ section#cookies-modal div.cookies-consent .cookies-actions button.accept {
}
}
}
.plan-categories-list {
.add-category,
.add-category:hover {
background-color: $secondary-dark;
border-color: $secondary-dark;
color: $secondary-text-color;
}
}

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
json.array!(@categories) do |category|
json.extract! category, :id, :name, :weight
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.extract! @category, :id, :name, :weight

View File

@ -1288,6 +1288,22 @@ en:
report_will_be_destroyed: "Once the report has been processed, it will be deleted. This can't be undone, continue?"
report_removed: "The report has been deleted"
failed_to_remove: "An error occurred, unable to delete the report"
plans_categories:
manage_plans_categories: "Manage plans' categories"
plan_categories_list:
categories_list: "List of the plan's categories"
new_category: "New category"
name: "Name"
confirm_create: "Create the category"
category_created: "The new category was successfully created"
unable_to_create: "Unable to create the category: "
edit_category: "Edit the category"
confirm_edition: "Validate"
category_updated: "The category was successfully updated"
unable_to_update: "Unable to update the category: "
delete_category: "Delete a category"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this category? If you do, the plans associated with this category won't be sorted anymore."
#feature tour
tour:
conclusion:

View File

@ -1288,6 +1288,8 @@ fr:
report_will_be_destroyed: "Une fois le signalement traité, le rapport sera supprimé. Cette action est irréversible, continuer ?"
report_removed: "Le rapport a bien été supprimé"
failed_to_remove: "Une erreur est survenue, impossible de supprimer le rapport"
plans_categories:
manage_plans_categories: "Gérer les catégories des formules d'abonnement"
#feature tour
tour:
conclusion:

View File

@ -96,7 +96,8 @@ Rails.application.routes.draw do
resources :groups, only: %i[index create update destroy]
resources :subscriptions, only: %i[show update]
resources :plans, only: %i[index create update destroy show]
resources :plan_categories
resources :plans
resources :slots, only: [:update] do
put 'cancel', on: :member
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Allows to sort plans into categories
class CreatePlanCategories < ActiveRecord::Migration[5.2]
def change
create_table :plan_categories do |t|
t.string :name
t.integer :weight
t.timestamps
end
add_reference :plans, :plan_category, index: true, foreign_key: true
end
end