diff --git a/Gemfile b/Gemfile index fa5abd22d..46fc79260 100644 --- a/Gemfile +++ b/Gemfile @@ -50,9 +50,9 @@ group :test do gem 'faker' gem 'minitest-reporters' gem 'pdf-reader' + gem 'rubyXL' gem 'vcr', '6.0.0' gem 'webmock' - gem 'rubyXL' end group :production, :staging do @@ -67,7 +67,6 @@ gem 'pg_search' # authentication gem 'devise', '>= 4.6.0' - gem 'omniauth', '~> 1.9.0' gem 'omniauth-oauth2' gem 'omniauth_openid_connect' @@ -145,4 +144,6 @@ gem 'tzinfo-data' # compilation of dynamic stylesheets (home page & theme) gem 'sassc', '= 2.1.0' -gem 'redis-session-store' \ No newline at end of file +gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index dfb8000e3..fdeff967b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -48,6 +48,8 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) + acts_as_list (1.0.4) + activerecord (>= 4.2) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.1.0) @@ -494,6 +496,7 @@ DEPENDENCIES aasm actionpack-page_caching (= 1.2.2) active_record_query_trace + acts_as_list api-pagination apipie-rails awesome_print diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb new file mode 100644 index 000000000..87a0949e3 --- /dev/null +++ b/app/controllers/api/product_categories_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# API Controller for resources of type ProductCategory +# ProductCategorys are used to group products +class API::ProductCategoriesController < API::ApiController + before_action :authenticate_user!, except: :index + before_action :set_product_category, only: %i[show update destroy] + + def index + @product_categories = ProductCategoryService.list + end + + def show; end + + def create + authorize ProductCategory + @product_category = ProductCategory.new(product_category_params) + if @product_category.save + render status: :created + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product_category + + if @product_category.update(product_category_params) + render status: :ok + else + render json: @product_category.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product_category + ProductCategoryService.destroy(@product_category) + head :no_content + end + + private + + def set_product_category + @product_category = ProductCategory.find(params[:id]) + end + + def product_category_params + params.require(:product_category).permit(:name, :parent_id, :slug, :position) + end +end diff --git a/app/frontend/src/javascript/api/product-category.ts b/app/frontend/src/javascript/api/product-category.ts new file mode 100644 index 000000000..964ef4e8f --- /dev/null +++ b/app/frontend/src/javascript/api/product-category.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { ProductCategory } from '../models/product-category'; + +export default class ProductCategoryAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/product_categories'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/product_categories/${id}`); + return res?.data; + } + + static async create (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.post('/api/product_categories', { product_category: productCategory }); + return res?.data; + } + + static async update (productCategory: ProductCategory): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/product_categories/${productCategory.id}`, { product_category: productCategory }); + return res?.data; + } + + static async destroy (productCategoryId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/product_categories/${productCategoryId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx index b5500ff84..f7e57dc5c 100644 --- a/app/frontend/src/javascript/components/base/fab-input.tsx +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -36,11 +36,9 @@ export const FabInput: React.FC = ({ id, onChange, defaultValue, * If the default value changes, update the value of the input until there's no content in it. */ useEffect(() => { - if (!inputValue) { - setInputValue(defaultValue); - if (typeof onChange === 'function') { - onChange(defaultValue); - } + setInputValue(defaultValue); + if (typeof onChange === 'function') { + onChange(defaultValue); } }, [defaultValue]); diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/product-categories-list.tsx new file mode 100644 index 000000000..b818caabb --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoriesListProps { + productCategories: Array, + onEdit: (category: ProductCategory) => void, + onDelete: (categoryId: number) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +export const ProductCategoriesList: React.FC = ({ productCategories, onEdit, onDelete }) => { + /** + * Init the process of editing the given product category + */ + const editProductCategory = (category: ProductCategory): () => void => { + return (): void => { + onEdit(category); + }; + }; + + /** + * Init the process of delete the given product category + */ + const deleteProductCategory = (categoryId: number): () => void => { + return (): void => { + onDelete(categoryId); + }; + }; + + return ( +
+ {productCategories.map((category) => ( +
+ {category.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx new file mode 100644 index 000000000..6ae88f8a5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { HtmlTranslate } from '../base/html-translate'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { FabAlert } from '../base/fab-alert'; +import { FabButton } from '../base/fab-button'; +import { ProductCategoriesList } from './product-categories-list'; +import { ProductCategoryModal } from './product-category-modal'; +import { ProductCategory } from '../../models/product-category'; +import ProductCategoryAPI from '../../api/product-category'; + +declare const Application: IApplication; + +interface ProductCategoriesProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows a Tree list of all Product's Categories + */ +const ProductCategories: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [isOpenProductCategoryModal, setIsOpenProductCategoryModal] = useState(false); + const [productCategories, setProductCategories] = useState>([]); + const [productCategory, setProductCategory] = useState(null); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }, []); + + /** + * Open create new product category modal + */ + const openProductCategoryModal = () => { + setIsOpenProductCategoryModal(true); + }; + + /** + * toggle create/edit product category modal + */ + const toggleCreateAndEditProductCategoryModal = () => { + setIsOpenProductCategoryModal(!isOpenProductCategoryModal); + }; + + /** + * callback handle save product category success + */ + const onSaveProductCategorySuccess = (message: string) => { + setIsOpenProductCategoryModal(false); + onSuccess(message); + ProductCategoryAPI.index().then(data => { + setProductCategories(data); + }); + }; + + /** + * Open edit the product category modal + */ + const editProductCategory = (category: ProductCategory) => { + setProductCategory(category); + setIsOpenProductCategoryModal(true); + }; + + /** + * Delete a product category + */ + const deleteProductCategory = async (categoryId: number): Promise => { + try { + await ProductCategoryAPI.destroy(categoryId); + const data = await ProductCategoryAPI.index(); + setProductCategories(data); + onSuccess(t('app.admin.store.product_categories.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.product_categories.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.product_categories.the_categories')}

+ {t('app.admin.store.product_categories.create_a_product_category')} + + + + + +
+ ); +}; + +const ProductCategoriesWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('productCategories', react2angular(ProductCategoriesWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx new file mode 100644 index 000000000..9199d87a4 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Select from 'react-select'; +import slugify from 'slugify'; +import { FabInput } from '../base/fab-input'; +import { ProductCategory } from '../../models/product-category'; + +interface ProductCategoryFormProps { + productCategories: Array, + productCategory?: ProductCategory, + onChange: (field: string, value: string | number) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Form to set create/edit supporting documents type + */ +export const ProductCategoryForm: React.FC = ({ productCategories, productCategory, onChange }) => { + const { t } = useTranslation('admin'); + + // filter all first level product categorie + const parents = productCategories.filter(c => !c.parent_id); + + const [slug, setSlug] = useState(productCategory?.slug || ''); + + /** + * Return the default first level product category, formatted to match the react-select format + */ + const defaultValue = { value: productCategory?.parent_id, label: productCategory?.name }; + + /** + * Convert all parents to the react-select format + */ + const buildOptions = (): Array => { + return parents.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the selection of parent product category has changed. + */ + const handleCategoryParentChange = (option: selectOption): void => { + onChange('parent_id', option.value); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (value: string): void => { + onChange('name', value); + const _slug = slugify(value, { lower: true }); + setSlug(_slug); + onChange('slug', _slug); + }; + + /** + * Callback triggered when the slug has changed. + */ + const handleSlugChange = (value: string): void => { + onChange('slug', value); + }; + + return ( +
+
+
+ } + defaultValue={productCategory?.name || ''} + placeholder={t('app.admin.store.product_category_form.name')} + onChange={handleNameChange} + debounce={200} + required/> +
+
+ } + defaultValue={slug} + placeholder={t('app.admin.store.product_category_form.slug')} + onChange={handleSlugChange} + debounce={200} + required/> +
+
+