mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-20 14:54:15 +01:00
product files and images upload
This commit is contained in:
parent
ea1171ba0f
commit
0773e5bc82
@ -15,8 +15,13 @@ class API::ProductsController < API::ApiController
|
||||
def create
|
||||
authorize Product
|
||||
@product = Product.new(product_params)
|
||||
@product.amount = nil if @product.amount.zero?
|
||||
@product.amount *= 100 if @product.amount.present?
|
||||
if @product.amount.present?
|
||||
if @product.amount.zero?
|
||||
@product.amount = nil
|
||||
else
|
||||
@product.amount *= 100
|
||||
end
|
||||
end
|
||||
if @product.save
|
||||
render status: :created
|
||||
else
|
||||
@ -28,8 +33,13 @@ class API::ProductsController < API::ApiController
|
||||
authorize @product
|
||||
|
||||
product_parameters = product_params
|
||||
product_parameters[:amount] = nil if product_parameters[:amount].zero?
|
||||
product_parameters[:amount] = product_parameters[:amount] * 100 if product_parameters[:amount].present?
|
||||
if product_parameters[:amount].present?
|
||||
if product_parameters[:amount].zero?
|
||||
product_parameters[:amount] = nil
|
||||
else
|
||||
product_parameters[:amount] *= 100
|
||||
end
|
||||
end
|
||||
if @product.update(product_parameters)
|
||||
render status: :ok
|
||||
else
|
||||
@ -52,6 +62,9 @@ class API::ProductsController < API::ApiController
|
||||
def product_params
|
||||
params.require(:product).permit(:name, :slug, :sku, :description, :is_active,
|
||||
:product_category_id, :amount, :quantity_min,
|
||||
:low_stock_alert, :low_stock_threshold, machine_ids: [])
|
||||
:low_stock_alert, :low_stock_threshold,
|
||||
machine_ids: [],
|
||||
product_files_attributes: %i[id attachment _destroy],
|
||||
product_images_attributes: %i[id attachment _destroy])
|
||||
end
|
||||
end
|
||||
|
@ -1,5 +1,6 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { serialize } from 'object-to-formdata';
|
||||
import { Product } from '../models/product';
|
||||
|
||||
export default class ProductAPI {
|
||||
@ -14,12 +15,70 @@ export default class ProductAPI {
|
||||
}
|
||||
|
||||
static async create (product: Product): Promise<Product> {
|
||||
const res: AxiosResponse<Product> = await apiClient.post('/api/products', { product });
|
||||
const data = serialize({
|
||||
product: {
|
||||
...product,
|
||||
product_files_attributes: null,
|
||||
product_images_attributes: null
|
||||
}
|
||||
});
|
||||
data.delete('product[product_files_attributes]');
|
||||
data.delete('product[product_images_attributes]');
|
||||
product.product_files_attributes?.forEach((file, i) => {
|
||||
if (file?.attachment_files && file?.attachment_files[0]) {
|
||||
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
|
||||
}
|
||||
});
|
||||
product.product_images_attributes?.forEach((image, i) => {
|
||||
if (image?.attachment_files && image?.attachment_files[0]) {
|
||||
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
|
||||
}
|
||||
});
|
||||
const res: AxiosResponse<Product> = await apiClient.post('/api/products', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async update (product: Product): Promise<Product> {
|
||||
const res: AxiosResponse<Product> = await apiClient.patch(`/api/products/${product.id}`, { product });
|
||||
const data = serialize({
|
||||
product: {
|
||||
...product,
|
||||
product_files_attributes: null,
|
||||
product_images_attributes: null
|
||||
}
|
||||
});
|
||||
data.delete('product[product_files_attributes]');
|
||||
data.delete('product[product_images_attributes]');
|
||||
product.product_files_attributes?.forEach((file, i) => {
|
||||
if (file?.attachment_files && file?.attachment_files[0]) {
|
||||
data.set(`product[product_files_attributes][${i}][attachment]`, file.attachment_files[0]);
|
||||
}
|
||||
if (file?.id) {
|
||||
data.set(`product[product_files_attributes][${i}][id]`, file.id.toString());
|
||||
}
|
||||
if (file?._destroy) {
|
||||
data.set(`product[product_files_attributes][${i}][_destroy]`, file._destroy.toString());
|
||||
}
|
||||
});
|
||||
product.product_images_attributes?.forEach((image, i) => {
|
||||
if (image?.attachment_files && image?.attachment_files[0]) {
|
||||
data.set(`product[product_images_attributes][${i}][attachment]`, image.attachment_files[0]);
|
||||
}
|
||||
if (image?.id) {
|
||||
data.set(`product[product_images_attributes][${i}][id]`, image.id.toString());
|
||||
}
|
||||
if (image?._destroy) {
|
||||
data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString());
|
||||
}
|
||||
});
|
||||
const res: AxiosResponse<Product> = await apiClient.patch(`/api/products/${product.id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
|
129
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal file
129
app/frontend/src/javascript/components/form/form-file-upload.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Path } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
|
||||
export interface FileType {
|
||||
id?: number,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string
|
||||
}
|
||||
|
||||
interface FormFileUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
defaultFile?: FileType,
|
||||
accept?: string,
|
||||
onFileChange?: (value: FileType) => void,
|
||||
onFileRemove?: () => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows to upload file, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormFileUpload = <TFieldValues extends FieldValues>({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [file, setFile] = useState<FileType>(defaultFile);
|
||||
|
||||
/**
|
||||
* Check if file is selected
|
||||
*/
|
||||
const hasFile = (): boolean => {
|
||||
return !!file?.attachment_name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
|
||||
*/
|
||||
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = event.target?.files[0];
|
||||
if (f) {
|
||||
setFile({
|
||||
attachment_name: f.name
|
||||
});
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
if (typeof onFileChange === 'function') {
|
||||
onFileChange({ attachment_name: f.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the delete button.
|
||||
*/
|
||||
function onRemoveFile () {
|
||||
if (file?.id) {
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setValue(
|
||||
`${id}[attachment_files]` as Path<TFieldValues>,
|
||||
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setFile(null);
|
||||
if (typeof onFileRemove === 'function') {
|
||||
onFileRemove();
|
||||
}
|
||||
}
|
||||
|
||||
// Compose classnames from props
|
||||
const classNames = [
|
||||
`${className || ''}`
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={`form-file-upload fileinput ${classNames}`}>
|
||||
<div className="filename-container">
|
||||
{hasFile() && (
|
||||
<div>
|
||||
<i className="fa fa-file fileinput-exists" />
|
||||
<span className="fileinput-filename">
|
||||
{file.attachment_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{file?.id && file?.attachment_url && (
|
||||
<a href={file.attachment_url}
|
||||
target="_blank"
|
||||
className="file-download"
|
||||
rel="noreferrer">
|
||||
<i className="fa fa-download"/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className="fileinput-button">
|
||||
{!hasFile() && (
|
||||
<span className="fileinput-new">{t('app.shared.form_file_upload.browse')}</span>
|
||||
)}
|
||||
{hasFile() && (
|
||||
<span className="fileinput-exists">{t('app.shared.form_file_upload.edit')}</span>
|
||||
)}
|
||||
<FormInput type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}/>
|
||||
</span>
|
||||
{hasFile() && (
|
||||
<a className="fileinput-exists fileinput-delete" onClick={onRemoveFile}>
|
||||
<i className="fa fa-trash-o"></i>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Path } from 'react-hook-form';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormComponent } from '../../models/form-component';
|
||||
import { AbstractFormItemProps } from './abstract-form-item';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import noAvatar from '../../../../images/no_avatar.png';
|
||||
|
||||
export interface ImageType {
|
||||
id?: number,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string
|
||||
}
|
||||
|
||||
interface FormImageUploadProps<TFieldValues> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
defaultImage?: ImageType,
|
||||
accept?: string,
|
||||
size?: 'small' | 'large'
|
||||
onFileChange?: (value: ImageType) => void,
|
||||
onFileRemove?: () => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows to upload image, in forms managed by react-hook-form.
|
||||
*/
|
||||
export const FormImageUpload = <TFieldValues extends FieldValues>({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('shared');
|
||||
|
||||
const [file, setFile] = useState<ImageType>(defaultImage);
|
||||
const [image, setImage] = useState<string | ArrayBuffer>(defaultImage.attachment_url);
|
||||
|
||||
/**
|
||||
* Check if image is selected
|
||||
*/
|
||||
const hasImage = (): boolean => {
|
||||
return !!file?.attachment_name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback triggered when the user has ended its selection of a file (or when the selection has been cancelled).
|
||||
*/
|
||||
function onFileSelected (event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = event.target?.files[0];
|
||||
if (f) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => {
|
||||
setImage(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
setFile({
|
||||
attachment_name: f.name
|
||||
});
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
false as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
if (typeof onFileChange === 'function') {
|
||||
onFileChange({ attachment_name: f.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user clicks on the delete button.
|
||||
*/
|
||||
function onRemoveFile () {
|
||||
if (file?.id) {
|
||||
setValue(
|
||||
`${id}[_destroy]` as Path<TFieldValues>,
|
||||
true as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
}
|
||||
setValue(
|
||||
`${id}[attachment_files]` as Path<TFieldValues>,
|
||||
null as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
setFile(null);
|
||||
setImage(null);
|
||||
if (typeof onFileRemove === 'function') {
|
||||
onFileRemove();
|
||||
}
|
||||
}
|
||||
|
||||
// Compose classnames from props
|
||||
const classNames = [
|
||||
`${className || ''}`
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={`form-image-upload form-image-upload--${size} ${classNames}`}>
|
||||
<div className={`image image--${size}`}>
|
||||
<img src={image || noAvatar} />
|
||||
</div>
|
||||
<div className="buttons">
|
||||
<FabButton className="select-button">
|
||||
{!hasImage() && <span>{t('app.shared.form_image_upload.browse')}</span>}
|
||||
{hasImage() && <span>{t('app.shared.form_image_upload.edit')}</span>}
|
||||
<FormInput className="image-file-input"
|
||||
type="file"
|
||||
accept={accept}
|
||||
register={register}
|
||||
formState={formState}
|
||||
rules={rules}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
warning={warning}
|
||||
id={`${id}[attachment_files]`}
|
||||
onChange={onFileSelected}/>
|
||||
</FabButton>
|
||||
{hasImage() && <FabButton onClick={onRemoveFile} icon={<i className="fa fa-trash-o"/>} className="delete-image" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -31,7 +31,9 @@ const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
|
||||
external: 0
|
||||
},
|
||||
low_stock_alert: false,
|
||||
machine_ids: []
|
||||
machine_ids: [],
|
||||
product_files_attributes: [],
|
||||
product_images_attributes: []
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import slugify from 'slugify';
|
||||
import _ from 'lodash';
|
||||
@ -10,6 +10,8 @@ import { FormSwitch } from '../form/form-switch';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormChecklist } from '../form/form-checklist';
|
||||
import { FormRichText } from '../form/form-rich-text';
|
||||
import { FormFileUpload } from '../form/form-file-upload';
|
||||
import { FormImageUpload } from '../form/form-image-upload';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FabAlert } from '../base/fab-alert';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
@ -41,6 +43,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
|
||||
const output = useWatch<Product>({ control });
|
||||
const [isActivePrice, setIsActivePrice] = useState<boolean>(product.id && _.isFinite(product.amount) && product.amount > 0);
|
||||
const [productCategories, setProductCategories] = useState<selectOption[]>([]);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
@ -117,6 +120,46 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add new product file
|
||||
*/
|
||||
const addProductFile = () => {
|
||||
setValue('product_files_attributes', output.product_files_attributes.concat({}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a product file
|
||||
*/
|
||||
const handleRemoveProductFile = (i: number) => {
|
||||
return () => {
|
||||
const productFile = output.product_files_attributes[i];
|
||||
if (!productFile.id) {
|
||||
output.product_files_attributes.splice(i, 1);
|
||||
setValue('product_files_attributes', output.product_files_attributes);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Add new product image
|
||||
*/
|
||||
const addProductImage = () => {
|
||||
setValue('product_images_attributes', output.product_images_attributes.concat({}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a product image
|
||||
*/
|
||||
const handleRemoveProductImage = (i: number) => {
|
||||
return () => {
|
||||
const productImage = output.product_images_attributes[i];
|
||||
if (!productImage.id) {
|
||||
output.product_images_attributes.splice(i, 1);
|
||||
setValue('product_images_attributes', output.product_images_attributes);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
@ -187,6 +230,28 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_images')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_images_info" />
|
||||
</FabAlert>
|
||||
<div className="product-images">
|
||||
{output.product_images_attributes.map((image, i) => (
|
||||
<FormImageUpload key={i}
|
||||
defaultImage={image}
|
||||
id={`product_images_attributes[${i}]`}
|
||||
accept="image/*"
|
||||
size="large"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={image._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveProductImage(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={addProductImage}>{t('app.admin.store.product_form.add_product_image')}</FabButton>
|
||||
</div>
|
||||
<h4>{t('app.admin.store.product_form.assigning_category')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.assigning_category_info" />
|
||||
@ -218,6 +283,24 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
|
||||
paragraphTools={true}
|
||||
limit={1000}
|
||||
id="description" />
|
||||
<div>
|
||||
<h4>{t('app.admin.store.product_form.product_files')}</h4>
|
||||
<FabAlert level="warning">
|
||||
<HtmlTranslate trKey="app.admin.store.product_form.product_files_info" />
|
||||
</FabAlert>
|
||||
{output.product_files_attributes.map((file, i) => (
|
||||
<FormFileUpload key={i}
|
||||
defaultFile={file}
|
||||
id={`product_files_attributes[${i}]`}
|
||||
accept="application/pdf"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formState={formState}
|
||||
className={file._destroy ? 'hidden' : ''}
|
||||
onFileRemove={handleRemoveProductFile(i)}/>
|
||||
))}
|
||||
<FabButton onClick={addProductFile}>{t('app.admin.store.product_form.add_product_file')}</FabButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-actions">
|
||||
<FabButton type="submit" className="main-action-btn">{t('app.admin.store.product_form.save')}</FabButton>
|
||||
|
@ -22,4 +22,20 @@ export interface Product {
|
||||
low_stock_alert: boolean,
|
||||
low_stock_threshold?: number,
|
||||
machine_ids: number[],
|
||||
product_files_attributes: Array<{
|
||||
id?: number,
|
||||
attachment?: File,
|
||||
attachment_files?: FileList,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string
|
||||
_destroy?: boolean
|
||||
}>,
|
||||
product_images_attributes: Array<{
|
||||
id?: number,
|
||||
attachment?: File,
|
||||
attachment_files?: FileList,
|
||||
attachment_name?: string,
|
||||
attachment_url?: string
|
||||
_destroy?: boolean
|
||||
}>
|
||||
}
|
||||
|
@ -39,6 +39,8 @@
|
||||
@import "modules/form/form-rich-text";
|
||||
@import "modules/form/form-switch";
|
||||
@import "modules/form/form-checklist";
|
||||
@import "modules/form/form-file-upload";
|
||||
@import "modules/form/form-image-upload";
|
||||
@import "modules/group/change-group";
|
||||
@import "modules/machines/machine-card";
|
||||
@import "modules/machines/machines-filters";
|
||||
@ -102,6 +104,7 @@
|
||||
@import "modules/user/gender-input";
|
||||
@import "modules/user/user-profile-form";
|
||||
@import "modules/user/user-validation";
|
||||
@import "modules/store/product-form";
|
||||
|
||||
@import "modules/abuses";
|
||||
@import "modules/cookies";
|
||||
|
106
app/frontend/src/stylesheets/modules/form/form-file-upload.scss
Normal file
106
app/frontend/src/stylesheets/modules/form/form-file-upload.scss
Normal file
@ -0,0 +1,106 @@
|
||||
.fileinput {
|
||||
display: table;
|
||||
border-collapse: separate;
|
||||
position: relative;
|
||||
margin-bottom: 9px;
|
||||
|
||||
.filename-container {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
background-color: #fff;
|
||||
background-image: none;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
|
||||
height: 38px;
|
||||
padding: 6px 12px;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
|
||||
.fileinput-filename {
|
||||
vertical-align: bottom;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.file-download {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
|
||||
i {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileinput-button {
|
||||
z-index: 1;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
background-color: #eee;
|
||||
color: #555;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
width: 1%;
|
||||
display: table-cell;
|
||||
background-image: none;
|
||||
touch-action: manipulation;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
||||
.form-input {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
input[type=file] {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
direction: ltr;
|
||||
filter: alpha(opacity=0);
|
||||
font-size: 23px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fileinput-delete {
|
||||
padding: 6px 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: #555555;
|
||||
text-align: center;
|
||||
background-color: #eeeeee;
|
||||
border: 1px solid #c4c4c4;
|
||||
border-radius: 4px;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
.form-image-upload {
|
||||
|
||||
.image {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--gray-soft);
|
||||
padding: 4px;
|
||||
display: inline-block;
|
||||
|
||||
&--small img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
&--large img {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.select-button {
|
||||
position: relative;
|
||||
.image-file-input {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
.delete-image {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
margin: 80px 40px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
.product-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
@ -6,6 +6,12 @@ class Product < ApplicationRecord
|
||||
|
||||
has_and_belongs_to_many :machines
|
||||
|
||||
has_many :product_files, as: :viewable, dependent: :destroy
|
||||
accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank
|
||||
|
||||
has_many :product_images, as: :viewable, dependent: :destroy
|
||||
accepts_nested_attributes_for :product_images, allow_destroy: true, reject_if: :all_blank
|
||||
|
||||
validates_numericality_of :amount, greater_than: 0, allow_nil: true
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
|
6
app/models/product_file.rb
Normal file
6
app/models/product_file.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ProductFile is a file stored on the file system, associated with a Product.
|
||||
class ProductFile < Asset
|
||||
mount_uploader :attachment, ProductFileUploader
|
||||
end
|
6
app/models/product_image.rb
Normal file
6
app/models/product_image.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ProductImage is an image stored on the file system, associated with a Product.
|
||||
class ProductImage < Asset
|
||||
mount_uploader :attachment, ProductImageUploader
|
||||
end
|
66
app/uploaders/product_file_uploader.rb
Normal file
66
app/uploaders/product_file_uploader.rb
Normal file
@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# CarrierWave uploader for file of product
|
||||
# This file defines the parameters for these uploads.
|
||||
class ProductFileUploader < CarrierWave::Uploader::Base
|
||||
# Include RMagick or MiniMagick support:
|
||||
# include CarrierWave::RMagick
|
||||
# include CarrierWave::MiniMagick
|
||||
include UploadHelper
|
||||
|
||||
# Choose what kind of storage to use for this uploader:
|
||||
storage :file
|
||||
# storage :fog
|
||||
|
||||
after :remove, :delete_empty_dirs
|
||||
|
||||
# Override the directory where uploaded files will be stored.
|
||||
# This is a sensible default for uploaders that are meant to be mounted:
|
||||
def store_dir
|
||||
"#{base_store_dir}/#{model.id}"
|
||||
end
|
||||
|
||||
def base_store_dir
|
||||
"uploads/#{model.class.to_s.underscore}"
|
||||
end
|
||||
|
||||
# Provide a default URL as a default if there hasn't been a file uploaded:
|
||||
# def default_url
|
||||
# # For Rails 3.1+ asset pipeline compatibility:
|
||||
# # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_'))
|
||||
#
|
||||
# "/images/fallback/" + [version_name, "default.png"].compact.join('_')
|
||||
# end
|
||||
|
||||
# Process files as they are uploaded:
|
||||
# process :scale => [200, 300]
|
||||
#
|
||||
# def scale(width, height)
|
||||
# # do something
|
||||
# end
|
||||
|
||||
# Create different versions of your uploaded files:
|
||||
# version :thumb do
|
||||
# process :resize_to_fit => [50, 50]
|
||||
# end
|
||||
|
||||
# Add a white list of extensions which are allowed to be uploaded.
|
||||
# For images you might use something like this:
|
||||
def extension_whitelist
|
||||
%w[pdf]
|
||||
end
|
||||
|
||||
def content_type_whitelist
|
||||
['application/pdf']
|
||||
end
|
||||
|
||||
# Override the filename of the uploaded files:
|
||||
# Avoid using model.id or version_name here, see uploader/store.rb for details.
|
||||
def filename
|
||||
if original_filename
|
||||
original_filename.split('.').map do |s|
|
||||
ActiveSupport::Inflector.transliterate(s).to_s
|
||||
end.join('.')
|
||||
end
|
||||
end
|
||||
end
|
76
app/uploaders/product_image_uploader.rb
Normal file
76
app/uploaders/product_image_uploader.rb
Normal file
@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# CarrierWave uploader for image of product
|
||||
# This file defines the parameters for these uploads.
|
||||
class ProductImageUploader < CarrierWave::Uploader::Base
|
||||
include CarrierWave::MiniMagick
|
||||
include UploadHelper
|
||||
|
||||
# Choose what kind of storage to use for this uploader:
|
||||
storage :file
|
||||
after :remove, :delete_empty_dirs
|
||||
|
||||
# Override the directory where uploaded files will be stored.
|
||||
# This is a sensible default for uploaders that are meant to be mounted:
|
||||
def store_dir
|
||||
"#{base_store_dir}/#{model.id}"
|
||||
end
|
||||
|
||||
def base_store_dir
|
||||
"uploads/#{model.class.to_s.underscore}"
|
||||
end
|
||||
|
||||
# Provide a default URL as a default if there hasn't been a file uploaded:
|
||||
# def default_url
|
||||
# # For Rails 3.1+ asset pipeline compatibility:
|
||||
# # ActionController::Base.helpers.asset_pack_path("fallback/" + [version_name, "default.png"].compact.join('_'))
|
||||
#
|
||||
# "/images/fallback/" + [version_name, "default.png"].compact.join('_')
|
||||
# end
|
||||
|
||||
# Process files as they are uploaded:
|
||||
# process :scale => [200, 300]
|
||||
#
|
||||
# def scale(width, height)
|
||||
# # do something
|
||||
# end
|
||||
|
||||
# Create different versions of your uploaded files:
|
||||
# version :thumb do
|
||||
# process :resize_to_fit => [50, 50]
|
||||
# end
|
||||
|
||||
# Create different versions of your uploaded files:
|
||||
version :large do
|
||||
process resize_to_fit: [1000, 700]
|
||||
end
|
||||
|
||||
version :medium do
|
||||
process resize_to_fit: [700, 400]
|
||||
end
|
||||
|
||||
# Add a white list of extensions which are allowed to be uploaded.
|
||||
# For images you might use something like this:
|
||||
def extension_whitelist
|
||||
%w[jpg jpeg gif png]
|
||||
end
|
||||
|
||||
def content_type_whitelist
|
||||
[%r{image/}]
|
||||
end
|
||||
|
||||
# Override the filename of the uploaded files:
|
||||
# Avoid using model.id or version_name here, see uploader/store.rb for details.
|
||||
def filename
|
||||
if original_filename
|
||||
original_filename.split('.').map do |s|
|
||||
ActiveSupport::Inflector.transliterate(s).to_s
|
||||
end.join('.')
|
||||
end
|
||||
end
|
||||
|
||||
# return an array like [width, height]
|
||||
def dimensions
|
||||
::MiniMagick::Image.open(file.file)[:dimensions]
|
||||
end
|
||||
end
|
@ -2,3 +2,13 @@
|
||||
|
||||
json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold, :machine_ids
|
||||
json.amount product.amount / 100.0 if product.amount.present?
|
||||
json.product_files_attributes product.product_files do |f|
|
||||
json.id f.id
|
||||
json.attachment_name f.attachment_identifier
|
||||
json.attachment_url f.attachment_url
|
||||
end
|
||||
json.product_images_attributes product.product_images do |f|
|
||||
json.id f.id
|
||||
json.attachment_name f.attachment_identifier
|
||||
json.attachment_url f.attachment_url
|
||||
end
|
||||
|
@ -1954,4 +1954,10 @@ en:
|
||||
assigning_machines_info: "<strong>Information</strong></br>You can link one or more machines from your fablab to your product, this product will then be subject to the filters on the catalogue view.</br>The machines selected below will be linked to the product."
|
||||
product_description: "Product description"
|
||||
product_description_info: "<strong>Information</strong></br>This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet."
|
||||
product_files: "Document"
|
||||
product_files_info: "<strong>Information</strong></br>Add documents related to this product, the uploaded documents will be presented in the product sheet, in a separate block. You can only upload pdf documents."
|
||||
add_product_file: "Add a document"
|
||||
product_images: "Images of product"
|
||||
product_images_info: "<strong>Advice</strong></br>We advise you to use a square format, jpg or png, for jpgs, please use white for the background colour. The main visual will be the visual presented first in the product sheet."
|
||||
add_product_image: "Add an image"
|
||||
save: "Save"
|
||||
|
@ -1953,4 +1953,10 @@ fr:
|
||||
assigning_machines_info: "<strong>Information</strong></br>Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.</br>Les machines sélectionnées ci-dessous seront liées au produit."
|
||||
product_description: "Description du produit"
|
||||
product_description_info: "<strong>Information</strong></br>Cette description du produit sera présente dans la fiche du produit. Vous avez à disposition quelques styles rédactionnels pour créer la fiche du produit."
|
||||
product_files: "Documentation"
|
||||
product_files_info: "<strong>Information</strong></br>Ajouter des documents liés à ce produit, les document uploadés seront présentés dans la fiche produit, dans un bloc distinct. Vous pouvez uploadé des pdf uniquement."
|
||||
add_product_file: "Ajouter un document"
|
||||
product_images: "Visuel(s) du produit"
|
||||
product_images_info: "<strong>Conseils</strong></br>Nous vous conseillons d'utiliser un format carré, jpg ou png, pour les jpgs, merci de privilégier le blanc pour la couleur de fond. Le visuel principal sera le visuel présenté en premier dans la fiche produit."
|
||||
add_product_image: "Ajouter un visuel"
|
||||
save: "Enregistrer"
|
||||
|
@ -553,3 +553,9 @@ en:
|
||||
form_checklist:
|
||||
select_all: "Select all"
|
||||
unselect_all: "Unselect all"
|
||||
form_file_upload:
|
||||
browse: "Browse"
|
||||
edit: "Edit"
|
||||
form_image_upload:
|
||||
browse: "Browse"
|
||||
edit: "Edit"
|
||||
|
@ -552,3 +552,9 @@ fr:
|
||||
create_label: "Ajouter {VALUE}"
|
||||
form_check_list:
|
||||
select_all: "Tout sélectionner"
|
||||
form_file_upload:
|
||||
browse: "Parcourir"
|
||||
edit: "Modifier"
|
||||
form_image_upload:
|
||||
browse: "Parcourir"
|
||||
edit: "Modifier"
|
||||
|
Loading…
x
Reference in New Issue
Block a user