1
0
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:
Du Peng 2022-08-02 19:47:56 +02:00 committed by Sylvain
parent ea1171ba0f
commit 0773e5bc82
21 changed files with 785 additions and 9 deletions

View File

@ -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

View File

@ -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;
}

View 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>
);
};

View File

@ -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>
);
};

View File

@ -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: []
};
/**

View File

@ -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>

View File

@ -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
}>
}

View File

@ -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";

View 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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,4 @@
.product-images {
display: flex;
flex-wrap: wrap;
}

View File

@ -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) }

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"