From 7ac6f087de5ef1b940b56de8d84597dc5059b264 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 2 Aug 2022 14:38:03 +0200 Subject: [PATCH 01/38] New translations en.yml (Portuguese) --- config/locales/pt.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 5e0b6614f..8e9a1d2ac 100755 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -19,9 +19,9 @@ pt: extension_whitelist_error: "Você não tem permissão para fazer o upload de arquivos com esta extensão %{extension}, tipos permitidos: %{allowed_types}" extension_blacklist_error: "Você não tem permissão para carregar arquivos %{extension}, tipos proibidos: %{prohibited_types}" content_type_whitelist_error: "Você não tem permissão para enviar arquivos %{content_type}, tipos permitidos: %{allowed_types}" - rmagick_processing_error: "Failed to manipulate with rmagick, maybe it is not an image?" - mime_types_processing_error: "Failed to process file with MIME::Types, maybe not valid content-type?" - mini_magick_processing_error: "Failed to manipulate the file, maybe it is not an image?" + rmagick_processing_error: "Falha ao manipular com rmagick, talvez não seja uma imagem?" + mime_types_processing_error: "Falha ao processar arquivo com MIME::Types, talvez tipo de conteúdo inválido?" + mini_magick_processing_error: "Falha ao manipular o arquivo, talvez não seja uma imagem?" wrong_size: "é o tamanho errado (deveria ser %{file_size})" size_too_small: "é muito pequeno (deve ser pelo menos %{file_size})" size_too_big: "é muito grande (deve ser no máximo %{file_size})" From 3c6535ffe35d03f699f0080e09d4476d1685ded5 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 2 Aug 2022 14:38:04 +0200 Subject: [PATCH 02/38] New translations app.public.en.yml (Portuguese) --- config/locales/app.public.pt.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config/locales/app.public.pt.yml b/config/locales/app.public.pt.yml index c3a7f769f..efd003eb1 100755 --- a/config/locales/app.public.pt.yml +++ b/config/locales/app.public.pt.yml @@ -71,9 +71,9 @@ pt: email_is_required: "E-mail é obrigatório." your_password: "Sua senha" password_is_required: "Senha é obrigatório." - password_is_too_short: "Password is too short (minimum 12 characters)" - password_is_too_weak: "Password is too weak:" - password_is_too_weak_explanations: "minimum 12 characters, at least one uppercase letter, one lowercase letter, one number and one special character" + password_is_too_short: "Senha muito curta (mínimo 12 caracteres)" + password_is_too_weak: "A senha é muito fraca:" + password_is_too_weak_explanations: "mínimo de 12 caracteres, pelo menos uma letra maiúscula, uma letra minúscula, um número e um caractere especial" type_your_password_again: "Digite sua senha novamente" password_confirmation_is_required: "Confirmação de senha é obrigatório." password_does_not_match_with_confirmation: "A senha não é igual ao da confirmação." @@ -103,7 +103,7 @@ pt: used_for_reservation: "Estes dados serão utilizados em caso de alteração em uma das suas reservas" used_for_profile: "Estes dados serão exibidos apenas no seu perfil" public_profile: "Você terá um perfil público e outros usuários poderão associá-lo em seus projetos" - you_will_receive_confirmation_instructions_by_email_detailed: "If your e-mail address is valid, you will receive an email with instructions about how to confirm your account in a few minutes." + you_will_receive_confirmation_instructions_by_email_detailed: "Se seu endereço de e-mail for válido, você receberá em breve um e-mail com instruções sobre como confirmar sua conta." #password modification modal change_your_password: "Mudar sua senha" your_new_password: "Sua nova senha" @@ -119,7 +119,7 @@ pt: #confirmation modal you_will_receive_confirmation_instructions_by_email: "Você receberá instruções de confirmação por e-mail." #forgotten password modal - you_will_receive_in_a_moment_an_email_with_instructions_to_reset_your_password: "If your e-mail address is valid, you will receive in a moment an e-mail with instructions to reset your password." + you_will_receive_in_a_moment_an_email_with_instructions_to_reset_your_password: "Se o seu endereço de e-mail for válido, você receberá em breve um e-mail com instruções para redefinir sua senha." #Fab-manager's version version: "Versão:" upgrade_fabmanager: "Atualizar Fab-manager" From 9b44492971c95b1fb80047301ffc0cede51623d1 Mon Sep 17 00:00:00 2001 From: Guilherme Chaguri Date: Tue, 16 Aug 2022 11:02:25 -0300 Subject: [PATCH 03/38] (bug) Fix user reference for admin check Fix user variable reference when verifying whether the user is an administrator --- app/models/concerns/single_sign_on_concern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/concerns/single_sign_on_concern.rb b/app/models/concerns/single_sign_on_concern.rb index f23a6985c..fb5bffadc 100644 --- a/app/models/concerns/single_sign_on_concern.rb +++ b/app/models/concerns/single_sign_on_concern.rb @@ -124,7 +124,7 @@ module SingleSignOnConcern logger.debug "mapping sso field #{field} with value=#{value}" # we do not merge the email field if its end with the special value '-duplicate' as this means # that the user is currently merging with the account that have the same email than the sso - set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && user.admin?) + set_data_from_sso_mapping(field, value) unless (field == 'user.email' && value.end_with?('-duplicate')) || (field == 'user.group_id' && sso_user.admin?) end # run the account transfer in an SQL transaction to ensure data integrity From 86791869267fa2791f67de1372f7838a0fb68bf7 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 22 Aug 2022 13:35:36 +0200 Subject: [PATCH 04/38] updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80bea673a..1e70c2c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## next release +- Updated portuguese translations +- Fix a bug: wrong variable reference in `SingleSignOnConcern:Merge_form_sso` + ## v5.4.15 2022 August 1 - Improved security: adds redis-session-store to store session From 5f8a15bcbbc5c0503e6a9e3e2a4947be7ffdb1b6 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 22 Aug 2022 15:25:00 +0200 Subject: [PATCH 05/38] Fix [form-rich-text] focus --- CHANGELOG.md | 1 + .../components/form/abstract-form-item.tsx | 19 +++++++++++-------- .../components/form/form-rich-text.tsx | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e70c2c35..4c491cea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Updated portuguese translations - Fix a bug: wrong variable reference in `SingleSignOnConcern:Merge_form_sso` +- Fix a bug: wrong focus behavior on text editor ## v5.4.15 2022 August 1 diff --git a/app/frontend/src/javascript/components/form/abstract-form-item.tsx b/app/frontend/src/javascript/components/form/abstract-form-item.tsx index 679c2bde9..afa91a07a 100644 --- a/app/frontend/src/javascript/components/form/abstract-form-item.tsx +++ b/app/frontend/src/javascript/components/form/abstract-form-item.tsx @@ -9,15 +9,16 @@ export interface AbstractFormItemProps extends PropsWithChildren boolean), - onLabelClick?: (event: React.MouseEvent) => void, + onLabelClick?: (event: React.MouseEvent) => void, inLine?: boolean, + containerType?: 'label' | 'div' } /** * This abstract component should not be used directly. * Other forms components that are intended to be used with react-hook-form must extend this component. */ -export const AbstractFormItem = ({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, inLine, children }: AbstractFormItemProps) => { +export const AbstractFormItem = ({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, inLine, containerType, children }: AbstractFormItemProps) => { const [isDirty, setIsDirty] = useState(false); const [fieldError, setFieldError] = useState<{ message: string }>(error); const [isDisabled, setIsDisabled] = useState(false); @@ -52,16 +53,16 @@ export const AbstractFormItem = ({ id, label, * This function is called when the label is clicked. * It is used to focus the input. */ - function handleLabelClick (event: React.MouseEvent) { + function handleLabelClick (event: React.MouseEvent) { if (typeof onLabelClick === 'function') { onLabelClick(event); } } - return ( -
- } onChange={handleCurrencyUpdate} diff --git a/app/frontend/src/javascript/components/payment/select-gateway-modal.tsx b/app/frontend/src/javascript/components/payment/select-gateway-modal.tsx index cdd8dc3df..23d48df7d 100644 --- a/app/frontend/src/javascript/components/payment/select-gateway-modal.tsx +++ b/app/frontend/src/javascript/components/payment/select-gateway-modal.tsx @@ -38,7 +38,7 @@ export const SelectGatewayModal: React.FC = ({ isO // request the configured gateway to the API useEffect(() => { - SettingAPI.get(SettingName.PaymentGateway).then(gateway => { + SettingAPI.get('payment_gateway').then(gateway => { setSelectedGateway(gateway.value ? gateway.value : ''); }); }, []); @@ -73,8 +73,8 @@ export const SelectGatewayModal: React.FC = ({ isO const handleValidStripeKeys = (publicKey: string, secretKey: string): void => { setGatewayConfig((prev) => { const newMap = new Map(prev); - newMap.set(SettingName.StripeSecretKey, secretKey); - newMap.set(SettingName.StripePublicKey, publicKey); + newMap.set('stripe_secret_key', secretKey); + newMap.set('stripe_public_key', publicKey); return newMap; }); setPreventConfirmGateway(false); @@ -100,7 +100,7 @@ export const SelectGatewayModal: React.FC = ({ isO */ const updateSettings = (): void => { const settings = new Map(gatewayConfig); - settings.set(SettingName.PaymentGateway, selectedGateway); + settings.set('payment_gateway', selectedGateway); SettingAPI.bulkUpdate(settings, true).then(result => { const errorResults = Array.from(result.values()).filter(item => !item.status); diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx index 68a912733..803140f63 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx @@ -14,7 +14,7 @@ export const StripeElements: React.FC = memo(({ children }) => { * When this component is mounted, we initialize the tag with the Stripe's public key */ useEffect(() => { - SettingAPI.get(SettingName.StripePublicKey).then(key => { + SettingAPI.get('stripe_public_key').then(key => { if (key?.value) { const promise = loadStripe(key.value); setStripe(promise); diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx index ee01742ce..9c96ae6fe 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx @@ -42,9 +42,9 @@ const StripeKeysForm: React.FC = ({ onValidKeys, onInvalidK useEffect(() => { mounted.current = true; - SettingAPI.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => { - setPublicKey(stripeKeys.get(SettingName.StripePublicKey)); - setSecretKey(stripeKeys.get(SettingName.StripeSecretKey)); + SettingAPI.query(['stripe_public_key', 'stripe_secret_key']).then(stripeKeys => { + setPublicKey(stripeKeys.get('stripe_public_key')); + setSecretKey(stripeKeys.get('stripe_secret_key')); }).catch(error => console.error(error)); // when the component unmounts, mark it as unmounted diff --git a/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx b/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx index 1723bddc8..ea5682538 100644 --- a/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx +++ b/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx @@ -44,10 +44,10 @@ const PacksSummary: React.FC = ({ item, itemType, customer, o const [isPackOnlyForSubscription, setIsPackOnlyForSubscription] = useState(true); useEffect(() => { - SettingAPI.get(SettingName.RenewPackThreshold) + SettingAPI.get('renew_pack_threshold') .then(data => setThreshold(parseFloat(data.value))) .catch(error => onError(error)); - SettingAPI.get(SettingName.PackOnlyForSubscription) + SettingAPI.get('pack_only_for_subscription') .then(data => setIsPackOnlyForSubscription(data.value === 'true')) .catch(error => onError(error)); }, []); diff --git a/app/frontend/src/javascript/components/profile-completion/completion-header-info.tsx b/app/frontend/src/javascript/components/profile-completion/completion-header-info.tsx index fc6085873..7b66a8d9e 100644 --- a/app/frontend/src/javascript/components/profile-completion/completion-header-info.tsx +++ b/app/frontend/src/javascript/components/profile-completion/completion-header-info.tsx @@ -6,7 +6,7 @@ import { Loader } from '../base/loader'; import { react2angular } from 'react2angular'; import { IApplication } from '../../models/application'; import SettingAPI from '../../api/setting'; -import { SettingName } from '../../models/setting'; +import { SettingName, titleSettings } from '../../models/setting'; import UserLib from '../../lib/user'; declare const Application: IApplication; @@ -27,7 +27,7 @@ export const CompletionHeaderInfo: React.FC = ({ user const userLib = new UserLib(user); useEffect(() => { - SettingAPI.query([SettingName.NameGenre, SettingName.FablabName]).then(setSettings).catch(onError); + SettingAPI.query(titleSettings).then(setSettings).catch(onError); }, []); return ( @@ -39,8 +39,8 @@ export const CompletionHeaderInfo: React.FC = ({ user

{t('app.logged.profile_completion.completion_header_info.sso_intro', { - GENDER: settings?.get(SettingName.NameGenre), - NAME: settings?.get(SettingName.FablabName) + GENDER: settings?.get('name_genre'), + NAME: settings?.get('fablab_name') })} diff --git a/app/frontend/src/javascript/components/settings/user-validation-setting.tsx b/app/frontend/src/javascript/components/settings/user-validation-setting.tsx index 414d016a2..9c65b5ed3 100644 --- a/app/frontend/src/javascript/components/settings/user-validation-setting.tsx +++ b/app/frontend/src/javascript/components/settings/user-validation-setting.tsx @@ -36,7 +36,7 @@ export const UserValidationSetting: React.FC = ({ on const updateSetting = (name: SettingName, value: string) => { SettingAPI.update(name, value) .then(() => { - if (name === SettingName.UserValidationRequired) { + if (name === 'user_validation_required') { onSuccess(t('app.admin.settings.account.user_validation_setting.customization_of_SETTING_successfully_saved', { SETTING: t(`app.admin.settings.account.${name}`) // eslint-disable-line fabmanager/scoped-translation })); @@ -45,7 +45,7 @@ export const UserValidationSetting: React.FC = ({ on if (err.status === 304) return; if (err.status === 423) { - if (name === SettingName.UserValidationRequired) { + if (name === 'user_validation_required') { onError(t('app.admin.settings.account.user_validation_setting.error_SETTING_locked', { SETTING: t(`app.admin.settings.account.${name}`) // eslint-disable-line fabmanager/scoped-translation })); @@ -62,19 +62,19 @@ export const UserValidationSetting: React.FC = ({ on * Callback triggered when the 'save' button is clicked. */ const handleSave = () => { - updateSetting(SettingName.UserValidationRequired, userValidationRequired); + updateSetting('user_validation_required', userValidationRequired); if (userValidationRequiredList !== null) { if (userValidationRequired === 'true') { - updateSetting(SettingName.UserValidationRequiredList, userValidationRequiredList); + updateSetting('user_validation_required_list', userValidationRequiredList); } else { - updateSetting(SettingName.UserValidationRequiredList, null); + updateSetting('user_validation_required_list', null); } } }; return (

- = ({ on {t('app.admin.settings.account.user_validation_setting.user_validation_required_list_other_info')} - = ({ show = false, onError, onSuccess }) => { const { t } = useTranslation('shared'); - // regular expression to validate the the input fields + // regular expression to validate the input fields const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/; const { handleSubmit, register, setValue, formState } = useForm(); diff --git a/app/frontend/src/javascript/components/user/user-profile-form.tsx b/app/frontend/src/javascript/components/user/user-profile-form.tsx index 18feb3570..1c449c0e5 100644 --- a/app/frontend/src/javascript/components/user/user-profile-form.tsx +++ b/app/frontend/src/javascript/components/user/user-profile-form.tsx @@ -99,7 +99,7 @@ export const UserProfileForm: React.FC = ({ action, size, }); setValue('invoicing_profile_attributes.user_profile_custom_fields_attributes', userProfileCustomFields); }).catch(error => onError(error)); - SettingAPI.query([SettingName.PhoneRequired, SettingName.AddressRequired]) + SettingAPI.query(['phone_required', 'address_required']) .then(settings => setRequiredFieldsSettings(settings)) .catch(error => onError(error)); }, []); @@ -219,7 +219,7 @@ export const UserProfileForm: React.FC = ({ action, size, value: phoneRegex, message: t('app.shared.user_profile_form.phone_number_invalid') }, - required: requiredFieldsSettings.get(SettingName.PhoneRequired) === 'true' + required: requiredFieldsSettings.get('phone_required') === 'true' }} disabled={isDisabled} formState={formState} @@ -232,7 +232,7 @@ export const UserProfileForm: React.FC = ({ action, size,
diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index 84eb6ed02..144c53631 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -1,144 +1,242 @@ import { HistoryValue } from './history-value'; import { TDateISO } from '../typings/date-iso'; -export enum SettingName { - AboutTitle = 'about_title', - AboutBody = 'about_body', - AboutContacts = 'about_contacts', - PrivacyDraft = 'privacy_draft', - PrivacyBody = 'privacy_body', - PrivacyDpo = 'privacy_dpo', - TwitterName = 'twitter_name', - HomeBlogpost = 'home_blogpost', - MachineExplicationsAlert = 'machine_explications_alert', - TrainingExplicationsAlert = 'training_explications_alert', - TrainingInformationMessage = 'training_information_message', - SubscriptionExplicationsAlert = 'subscription_explications_alert', - InvoiceLogo = 'invoice_logo', - InvoiceReference = 'invoice_reference', - InvoiceCodeActive = 'invoice_code-active', - InvoiceCodeValue = 'invoice_code-value', - InvoiceOrderNb = 'invoice_order-nb', - InvoiceVATActive = 'invoice_VAT-active', - InvoiceVATRate = 'invoice_VAT-rate', - InvoiceVATRateMachine = 'invoice_VAT-rate_Machine', - InvoiceVATRateTraining = 'invoice_VAT-rate_Training', - InvoiceVATRateSpace = 'invoice_VAT-rate_Space', - InvoiceVATRateEvent = 'invoice_VAT-rate_Event', - InvoiceVATRateSubscription = 'invoice_VAT-rate_Subscription', - InvoiceText = 'invoice_text', - InvoiceLegals = 'invoice_legals', - BookingWindowStart = 'booking_window_start', - BookingWindowEnd = 'booking_window_end', - BookingMoveEnable = 'booking_move_enable', - BookingMoveDelay = 'booking_move_delay', - BookingCancelEnable = 'booking_cancel_enable', - BookingCancelDelay = 'booking_cancel_delay', - MainColor = 'main_color', - SecondaryColor = 'secondary_color', - FablabName = 'fablab_name', - NameGenre = 'name_genre', - ReminderEnable = 'reminder_enable', - ReminderDelay = 'reminder_delay', - EventExplicationsAlert = 'event_explications_alert', - SpaceExplicationsAlert = 'space_explications_alert', - VisibilityYearly = 'visibility_yearly', - VisibilityOthers = 'visibility_others', - DisplayNameEnable = 'display_name_enable', - MachinesSortBy = 'machines_sort_by', - AccountingJournalCode = 'accounting_journal_code', - AccountingCardClientCode = 'accounting_card_client_code', - AccountingCardClientLabel = 'accounting_card_client_label', - AccountingWalletClientCode = 'accounting_wallet_client_code', - AccountingWalletClientLabel = 'accounting_wallet_client_label', - AccountingOtherClientCode = 'accounting_other_client_code', - AccountingOtherClientLabel = 'accounting_other_client_label', - AccountingWalletCode = 'accounting_wallet_code', - AccountingWalletLabel = 'accounting_wallet_label', - AccountingVATCode = 'accounting_VAT_code', - AccountingVATLabel = 'accounting_VAT_label', - AccountingSubscriptionCode = 'accounting_subscription_code', - AccountingSubscriptionLabel = 'accounting_subscription_label', - AccountingMachineCode = 'accounting_Machine_code', - AccountingMachineLabel = 'accounting_Machine_label', - AccountingTrainingCode = 'accounting_Training_code', - AccountingTrainingLabel = 'accounting_Training_label', - AccountingEventCode = 'accounting_Event_code', - AccountingEventLabel = 'accounting_Event_label', - AccountingSpaceCode = 'accounting_Space_code', - AccountingSpaceLabel = 'accounting_Space_label', - HubLastVersion = 'hub_last_version', - HubPublicKey = 'hub_public_key', - FabAnalytics = 'fab_analytics', - LinkName = 'link_name', - HomeContent = 'home_content', - HomeCss = 'home_css', - Origin = 'origin', - Uuid = 'uuid', - PhoneRequired = 'phone_required', - TrackingId = 'tracking_id', - BookOverlappingSlots = 'book_overlapping_slots', - SlotDuration = 'slot_duration', - EventsInCalendar = 'events_in_calendar', - SpacesModule = 'spaces_module', - PlansModule = 'plans_module', - InvoicingModule = 'invoicing_module', - FacebookAppId = 'facebook_app_id', - TwitterAnalytics = 'twitter_analytics', - RecaptchaSiteKey = 'recaptcha_site_key', - RecaptchaSecretKey = 'recaptcha_secret_key', - FeatureTourDisplay = 'feature_tour_display', - EmailFrom = 'email_from', - DisqusShortname = 'disqus_shortname', - AllowedCadExtensions = 'allowed_cad_extensions', - AllowedCadMimeTypes = 'allowed_cad_mime_types', - OpenlabAppId = 'openlab_app_id', - OpenlabAppSecret = 'openlab_app_secret', - OpenlabDefault = 'openlab_default', - OnlinePaymentModule = 'online_payment_module', - StripePublicKey = 'stripe_public_key', - StripeSecretKey = 'stripe_secret_key', - StripeCurrency = 'stripe_currency', - InvoicePrefix = 'invoice_prefix', - ConfirmationRequired = 'confirmation_required', - WalletModule = 'wallet_module', - StatisticsModule = 'statistics_module', - UpcomingEventsShown = 'upcoming_events_shown', - PaymentSchedulePrefix = 'payment_schedule_prefix', - TrainingsModule = 'trainings_module', - AddressRequired = 'address_required', - PaymentGateway = 'payment_gateway', - PayZenUsername = 'payzen_username', - PayZenPassword = 'payzen_password', - PayZenEndpoint = 'payzen_endpoint', - PayZenPublicKey = 'payzen_public_key', - PayZenHmacKey = 'payzen_hmac', - PayZenCurrency = 'payzen_currency', - PublicAgendaModule = 'public_agenda_module', - RenewPackThreshold = 'renew_pack_threshold', - PackOnlyForSubscription = 'pack_only_for_subscription', - OverlappingCategories = 'overlapping_categories', - ExtendedPricesInSameDay = 'extended_prices_in_same_day', - PublicRegistrations = 'public_registrations', - SocialsFacebook = 'facebook', - SocialsTwitter = 'twitter', - SocialsViadeo = 'viadeo', - SocialsLinkedin = 'linkedin', - SocialsInstagram = 'instagram', - SocialsYoutube = 'youtube', - SocialsVimeo = 'vimeo', - SocialsDailymotion = 'dailymotion', - SocialsGithub = 'github', - SocialsEchosciences = 'echosciences', - SocialsPinterest = 'pinterest', - SocialsLastfm = 'lastfm', - SocialsFlickr = 'flickr', - MachinesModule = 'machines_module', - UserChangeGroup = 'user_change_group', - UserValidationRequired = 'user_validation_required', - UserValidationRequiredList = 'user_validation_required_list', - ShowUsernameInAdminList = 'show_username_in_admin_list' -} +export const homePageSettings = [ + 'twitter_name', + 'home_blogpost', + 'home_content', + 'home_css', + 'upcoming_events_shown' +]; + +export const privacyPolicySettings = [ + 'privacy_draft', + 'privacy_body', + 'privacy_dpo' +]; + +export const aboutPageSettings = [ + 'about_title', + 'about_body', + 'about_contacts', + 'link_name' +]; + +export const socialNetworksSettings = [ + 'facebook', + 'twitter', + 'viadeo', + 'linkedin', + 'instagram', + 'youtube', + 'vimeo', + 'dailymotion', + 'github', + 'echosciences', + 'pinterest', + 'lastfm', + 'flickr' +]; + +export const messagesSettings = [ + 'machine_explications_alert', + 'training_explications_alert', + 'training_information_message', + 'subscription_explications_alert', + 'event_explications_alert', + 'space_explications_alert' +]; + +export const invoicesSettings = [ + 'invoice_logo', + 'invoice_reference', + 'invoice_code-active', + 'invoice_code-value', + 'invoice_order-nb', + 'invoice_VAT-active', + 'invoice_VAT-rate', + 'invoice_VAT-rate_Machine', + 'invoice_VAT-rate_Training', + 'invoice_VAT-rate_Space', + 'invoice_VAT-rate_Event', + 'invoice_VAT-rate_Subscription', + 'invoice_text', + 'invoice_legals', + 'invoice_prefix', + 'payment_schedule_prefix' +]; + +export const bookingSettings = [ + 'booking_window_start', + 'booking_window_end', + 'booking_move_enable', + 'booking_move_delay', + 'booking_cancel_enable', + 'booking_cancel_delay', + 'reminder_enable', + 'reminder_delay', + 'visibility_yearly', + 'visibility_others', + 'display_name_enable', + 'book_overlapping_slots', + 'slot_duration', + 'overlapping_categories' +]; + +export const themeSettings = [ + 'main_color', + 'secondary_color' +]; + +export const titleSettings = [ + 'fablab_name', + 'name_genre' +]; + +export const accountingSettings = [ + 'accounting_journal_code', + 'accounting_card_client_code', + 'accounting_card_client_label', + 'accounting_wallet_client_code', + 'accounting_wallet_client_label', + 'accounting_other_client_code', + 'accounting_other_client_label', + 'accounting_wallet_code', + 'accounting_wallet_label', + 'accounting_VAT_code', + 'accounting_VAT_label', + 'accounting_subscription_code', + 'accounting_subscription_label', + 'accounting_Machine_code', + 'accounting_Machine_label', + 'accounting_Training_code', + 'accounting_Training_label', + 'accounting_Event_code', + 'accounting_Event_label', + 'accounting_Space_code', + 'accounting_Space_label' +]; + +export const modulesSettings = [ + 'spaces_module', + 'plans_module', + 'wallet_module', + 'statistics_module', + 'trainings_module', + 'machines_module', + 'online_payment_module', + 'public_agenda_module', + 'invoicing_module' +]; + +export const stripeSettings = [ + 'stripe_public_key', + 'stripe_secret_key', + 'stripe_currency' +]; + +export const payzenSettings = [ + 'payzen_username', + 'payzen_password', + 'payzen_endpoint', + 'payzen_public_key', + 'payzen_hmac', + 'payzen_currency' +]; + +export const openLabSettings = [ + 'openlab_app_id', + 'openlab_app_secret', + 'openlab_default' +]; + +export const accountSettings = [ + 'phone_required', + 'confirmation_required', + 'address_required', + 'user_change_group', + 'user_validation_required', + 'user_validation_required_list' +]; + +export const analyticsSettings = [ + 'tracking_id', + 'facebook_app_id', + 'twitter_analytics' +]; + +export const fabHubSettings = [ + 'hub_last_version', + 'hub_public_key', + 'fab_analytics', + 'origin', + 'uuid' +]; + +export const projectsSettings = [ + 'allowed_cad_extensions', + 'allowed_cad_mime_types', + 'disqus_shortname' +]; + +export const prepaidPacksSettings = [ + 'renew_pack_threshold', + 'pack_only_for_subscription' +]; + +export const registrationSettings = [ + 'public_registrations', + 'recaptcha_site_key', + 'recaptcha_secret_key' +]; + +export const adminSettings = [ + 'feature_tour_display', + 'show_username_in_admin_list' +]; + +export const pricingSettings = [ + 'extended_prices_in_same_day' +]; + +export const poymentSettings = [ + 'payment_gateway' +]; + +export const displaySettings = [ + 'machines_sort_by', + 'events_in_calendar', + 'email_from' +]; + +export const allSettings = [ + ...homePageSettings, + ...privacyPolicySettings, + ...aboutPageSettings, + ...socialNetworksSettings, + ...messagesSettings, + ...invoicesSettings, + ...bookingSettings, + ...themeSettings, + ...titleSettings, + ...accountingSettings, + ...modulesSettings, + ...stripeSettings, + ...payzenSettings, + ...openLabSettings, + ...accountSettings, + ...analyticsSettings, + ...fabHubSettings, + ...projectsSettings, + ...prepaidPacksSettings, + ...registrationSettings, + ...adminSettings, + ...pricingSettings, + ...poymentSettings, + ...displaySettings +] as const; + +export type SettingName = typeof allSettings[number]; export type SettingValue = string|boolean|number; @@ -153,7 +251,7 @@ export interface Setting { export interface SettingError { error: string, id: number, - name: string + name: SettingName } export interface SettingBulkResult { From faae719fefac0034d9f48c6acb95b097bf5275f5 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 23 Aug 2022 15:15:49 +0200 Subject: [PATCH 08/38] Removed unused imports --- app/frontend/src/javascript/components/group/change-group.tsx | 1 - .../src/javascript/components/machines/reserve-button.tsx | 1 - .../components/payment-schedule/payment-schedules-table.tsx | 2 +- .../javascript/components/payment/abstract-payment-modal.tsx | 1 - .../src/javascript/components/payment/card-payment-modal.tsx | 2 +- .../components/payment/local-payment/local-payment-form.tsx | 1 - .../src/javascript/components/payment/payzen/payzen-form.tsx | 1 - .../javascript/components/payment/stripe/stripe-elements.tsx | 1 - .../javascript/components/payment/stripe/stripe-keys-form.tsx | 1 - .../src/javascript/components/prepaid-packs/packs-summary.tsx | 1 - 10 files changed, 2 insertions(+), 10 deletions(-) diff --git a/app/frontend/src/javascript/components/group/change-group.tsx b/app/frontend/src/javascript/components/group/change-group.tsx index d8a59f80f..653f65931 100644 --- a/app/frontend/src/javascript/components/group/change-group.tsx +++ b/app/frontend/src/javascript/components/group/change-group.tsx @@ -11,7 +11,6 @@ import { useForm } from 'react-hook-form'; import { FormSelect } from '../form/form-select'; import MemberAPI from '../../api/member'; import SettingAPI from '../../api/setting'; -import { SettingName } from '../../models/setting'; import UserLib from '../../lib/user'; declare const Application: IApplication; diff --git a/app/frontend/src/javascript/components/machines/reserve-button.tsx b/app/frontend/src/javascript/components/machines/reserve-button.tsx index 2d0cdd6f6..40381c61d 100644 --- a/app/frontend/src/javascript/components/machines/reserve-button.tsx +++ b/app/frontend/src/javascript/components/machines/reserve-button.tsx @@ -10,7 +10,6 @@ import { Machine } from '../../models/machine'; import { User } from '../../models/user'; import { IApplication } from '../../models/application'; import SettingAPI from '../../api/setting'; -import { SettingName } from '../../models/setting'; declare const Application: IApplication; diff --git a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index 719c70ec3..b9fba4fbd 100644 --- a/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -12,7 +12,7 @@ import FormatLib from '../../lib/format'; import { PaymentScheduleItemActions, TypeOnce } from './payment-schedule-item-actions'; import { StripeElements } from '../payment/stripe/stripe-elements'; import SettingAPI from '../../api/setting'; -import { Setting, SettingName } from '../../models/setting'; +import { Setting } from '../../models/setting'; interface PaymentSchedulesTableProps { paymentSchedules: Array, diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx index 61ebb67f1..45e80c9f7 100644 --- a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -13,7 +13,6 @@ import PriceAPI from '../../api/price'; import WalletAPI from '../../api/wallet'; import { Invoice } from '../../models/invoice'; import SettingAPI from '../../api/setting'; -import { SettingName } from '../../models/setting'; import { GoogleTagManager } from '../../models/gtm'; import { ComputePriceResult } from '../../models/price'; import { Wallet } from '../../models/wallet'; diff --git a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx index 9263eddb5..525dd4d4d 100644 --- a/app/frontend/src/javascript/components/payment/card-payment-modal.tsx +++ b/app/frontend/src/javascript/components/payment/card-payment-modal.tsx @@ -7,7 +7,7 @@ import { IApplication } from '../../models/application'; import { ShoppingCart } from '../../models/payment'; import { User } from '../../models/user'; import { PaymentSchedule } from '../../models/payment-schedule'; -import { Setting, SettingName } from '../../models/setting'; +import { Setting } from '../../models/setting'; import { Invoice } from '../../models/invoice'; import SettingAPI from '../../api/setting'; import { useTranslation } from 'react-i18next'; diff --git a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx index aa0009b26..e8f5f5799 100644 --- a/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx +++ b/app/frontend/src/javascript/components/payment/local-payment/local-payment-form.tsx @@ -5,7 +5,6 @@ import { GatewayFormProps } from '../abstract-payment-modal'; import LocalPaymentAPI from '../../../api/local-payment'; import FormatLib from '../../../lib/format'; import SettingAPI from '../../../api/setting'; -import { SettingName } from '../../../models/setting'; import { CardPaymentModal } from '../card-payment-modal'; import { PaymentSchedule } from '../../../models/payment-schedule'; import { HtmlTranslate } from '../../base/html-translate'; diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index bae1b18e3..0c5bf8e86 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -2,7 +2,6 @@ import React, { FormEvent, FunctionComponent, useEffect, useRef, useState } from import KRGlue from '@lyracom/embedded-form-glue'; import { GatewayFormProps } from '../abstract-payment-modal'; import SettingAPI from '../../../api/setting'; -import { SettingName } from '../../../models/setting'; import PayzenAPI from '../../../api/payzen'; import { CreateTokenResponse, diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx index 803140f63..8ed376168 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx @@ -1,7 +1,6 @@ import React, { memo, useEffect, useState } from 'react'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe, Stripe } from '@stripe/stripe-js'; -import { SettingName } from '../../../models/setting'; import SettingAPI from '../../../api/setting'; /** diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx index 9c96ae6fe..1804cf922 100644 --- a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import { HtmlTranslate } from '../../base/html-translate'; import { FabInput } from '../../base/fab-input'; import { Loader } from '../../base/loader'; -import { SettingName } from '../../../models/setting'; import StripeAPI from '../../../api/external/stripe'; import SettingAPI from '../../../api/setting'; diff --git a/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx b/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx index ea5682538..1f4abde1b 100644 --- a/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx +++ b/app/frontend/src/javascript/components/prepaid-packs/packs-summary.tsx @@ -5,7 +5,6 @@ import { User } from '../../models/user'; import { UserPack } from '../../models/user-pack'; import UserPackAPI from '../../api/user-pack'; import SettingAPI from '../../api/setting'; -import { SettingName } from '../../models/setting'; import { FabButton } from '../base/fab-button'; import { useTranslation } from 'react-i18next'; import { ProposePacksModal } from './propose-packs-modal'; From f7261043b5372e57ac75d5a06e7b6254e5223aa9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 23 Aug 2022 15:54:37 +0200 Subject: [PATCH 09/38] Fix a bug: trainings monitoring is not available --- CHANGELOG.md | 1 + app/controllers/api/trainings_controller.rb | 8 +++++- .../trainings/availabilities.json.jbuilder | 10 ++++--- .../trainings/availabilities_test.rb | 26 +++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 test/integration/trainings/availabilities_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 280f90907..1484945a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Use union type instead of enum for SettingName - Fix a bug: wrong variable reference in `SingleSignOnConcern:Merge_form_sso` - Fix a bug: wrong focus behavior on text editor +- Fix a bug: trainings monitoring is not available ## v5.4.15 2022 August 1 diff --git a/app/controllers/api/trainings_controller.rb b/app/controllers/api/trainings_controller.rb index b8902da78..83240a232 100644 --- a/app/controllers/api/trainings_controller.rb +++ b/app/controllers/api/trainings_controller.rb @@ -52,7 +52,13 @@ class API::TrainingsController < API::ApiController authorize Training @training = Training.find(params[:id]) @availabilities = @training.availabilities - .includes(slots: { slots_reservations: { reservations: { statistic_profile: [:trainings, user: [:profile]] } } }) + .includes(slots: { + slots_reservations: { + reservation: { + statistic_profile: [:trainings, { user: [:profile] }] + } + } + }) .where('slots_reservations.canceled_at': nil) .order('availabilities.start_at DESC') end diff --git a/app/views/api/trainings/availabilities.json.jbuilder b/app/views/api/trainings/availabilities.json.jbuilder index b3701a28e..e4163caa1 100644 --- a/app/views/api/trainings/availabilities.json.jbuilder +++ b/app/views/api/trainings/availabilities.json.jbuilder @@ -1,11 +1,13 @@ +# frozen_string_literal: true + json.extract! @training, :id, :name, :description, :machine_ids, :nb_total_places, :public_page json.availabilities @availabilities do |a| json.id a.id json.start_at a.start_at.iso8601 json.end_at a.end_at.iso8601 - json.reservation_users a.slots.map do |slot| - json.id slot.reservations.first.statistic_profile.user_id - json.full_name slot.reservations.first.statistic_profile&.user&.profile&.full_name - json.is_valid slot.reservations.first.statistic_profile.trainings.include?(@training) + json.reservation_users a.slots.map(&:slots_reservations).flatten.map do |sr| + json.id sr.reservation.statistic_profile.user_id + json.full_name sr.reservation.statistic_profile.user&.profile&.full_name + json.is_valid sr.reservation.statistic_profile.trainings&.include?(@training) end end diff --git a/test/integration/trainings/availabilities_test.rb b/test/integration/trainings/availabilities_test.rb new file mode 100644 index 000000000..6e0c31450 --- /dev/null +++ b/test/integration/trainings/availabilities_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Trainings; end + +class Trainings::AvailabilitiesTest < ActionDispatch::IntegrationTest + def setup + @admin = User.find_by(username: 'admin') + login_as(@admin, scope: :user) + end + + test 'get trainings availabilities list' do + training = Training.find(1) + get "/api/trainings/#{training.id}/availabilities" + + # Check response format & status + assert_equal 200, response.status, response.body + assert_equal Mime[:json], response.content_type + + # Check the correct training was returned + result = json_response(response.body) + assert_equal training.id, result[:id], 'training id does not match' + assert_not_empty result[:availabilities], 'no training availabilities were returned' + end +end From 4396bb0ca07b7bcc4f52c56b8c546fbb57d9ff98 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 11 Jul 2022 19:17:36 +0200 Subject: [PATCH 10/38] store product category create/list/update/delete --- Gemfile | 2 + Gemfile.lock | 3 + .../api/product_categories_controller.rb | 50 ++++++++ .../src/javascript/api/product-category.ts | 30 +++++ .../javascript/components/base/fab-input.tsx | 8 +- .../store/product-categories-list.tsx | 50 ++++++++ .../components/store/product-categories.tsx | 114 ++++++++++++++++++ .../store/product-category-form.tsx | 99 +++++++++++++++ .../store/product-category-modal.tsx | 100 +++++++++++++++ .../src/javascript/controllers/admin/store.js | 38 ++++++ .../src/javascript/controllers/main_nav.js | 6 + .../src/javascript/models/product-category.ts | 7 ++ app/frontend/src/javascript/router.js | 10 ++ .../templates/admin/store/categories.html | 1 + app/frontend/templates/admin/store/index.html | 42 +++++++ .../templates/admin/store/orders.html | 1 + .../templates/admin/store/products.html | 1 + .../templates/admin/store/settings.html | 1 + app/models/product_category.rb | 12 ++ app/policies/product_category_policy.rb | 16 +++ app/services/product_category_service.rb | 13 ++ .../_product_category.json.jbuilder | 3 + .../product_categories/create.json.jbuilder | 3 + .../product_categories/index.json.jbuilder | 5 + .../api/product_categories/show.json.jbuilder | 3 + .../product_categories/update.json.jbuilder | 3 + config/locales/app.admin.en.yml | 24 ++++ config/locales/app.admin.fr.yml | 24 ++++ config/locales/app.public.en.yml | 1 + config/locales/app.public.fr.yml | 1 + config/routes.rb | 2 + ...0220620072750_create_product_categories.rb | 12 ++ db/schema.rb | 30 +++-- package.json | 3 + yarn.lock | 28 +++++ 35 files changed, 731 insertions(+), 15 deletions(-) create mode 100644 app/controllers/api/product_categories_controller.rb create mode 100644 app/frontend/src/javascript/api/product-category.ts create mode 100644 app/frontend/src/javascript/components/store/product-categories-list.tsx create mode 100644 app/frontend/src/javascript/components/store/product-categories.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-form.tsx create mode 100644 app/frontend/src/javascript/components/store/product-category-modal.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store.js create mode 100644 app/frontend/src/javascript/models/product-category.ts create mode 100644 app/frontend/templates/admin/store/categories.html create mode 100644 app/frontend/templates/admin/store/index.html create mode 100644 app/frontend/templates/admin/store/orders.html create mode 100644 app/frontend/templates/admin/store/products.html create mode 100644 app/frontend/templates/admin/store/settings.html create mode 100644 app/models/product_category.rb create mode 100644 app/policies/product_category_policy.rb create mode 100644 app/services/product_category_service.rb create mode 100644 app/views/api/product_categories/_product_category.json.jbuilder create mode 100644 app/views/api/product_categories/create.json.jbuilder create mode 100644 app/views/api/product_categories/index.json.jbuilder create mode 100644 app/views/api/product_categories/show.json.jbuilder create mode 100644 app/views/api/product_categories/update.json.jbuilder create mode 100644 db/migrate/20220620072750_create_product_categories.rb diff --git a/Gemfile b/Gemfile index 46d891218..b6ef82f40 100644 --- a/Gemfile +++ b/Gemfile @@ -145,3 +145,5 @@ gem 'tzinfo-data' gem 'sassc', '= 2.1.0' gem 'redis-session-store' + +gem 'acts_as_list' diff --git a/Gemfile.lock b/Gemfile.lock index 1abd78ea6..c07126939 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) @@ -500,6 +502,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/> +
+
+ -
-
-
+
+ { action === 'delete' + ? <> + + {t('app.admin.store.product_category_form.delete.confirm')} + + {t('app.admin.store.product_category_form.save')} + + : <> + + + + {t('app.admin.store.product_category_form.save')} + + } + ); }; diff --git a/app/frontend/src/javascript/components/store/product-category-modal.tsx b/app/frontend/src/javascript/components/store/product-category-modal.tsx deleted file mode 100644 index c2f3185dd..000000000 --- a/app/frontend/src/javascript/components/store/product-category-modal.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FabModal } from '../base/fab-modal'; -import { ProductCategoryForm } from './product-category-form'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; - -interface ProductCategoryModalProps { - isOpen: boolean, - toggleModal: () => void, - onSuccess: (message: string) => void, - onError: (message: string) => void, - productCategories: Array, - productCategory?: ProductCategory, -} - -/** - * Check if string is a valid url slug - */ -function checkIfValidURLSlug (str: string): boolean { - // Regular expression to check if string is a valid url slug - const regexExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; - - return regexExp.test(str); -} - -/** - * Modal dialog to create/edit a category of product - */ -export const ProductCategoryModal: React.FC = ({ isOpen, toggleModal, onSuccess, onError, productCategories, productCategory }) => { - const { t } = useTranslation('admin'); - - const [data, setData] = useState({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - - useEffect(() => { - setData({ - id: productCategory?.id, - name: productCategory?.name || '', - slug: productCategory?.slug || '', - parent_id: productCategory?.parent_id, - position: productCategory?.position - }); - }, [productCategory]); - - /** - * Callback triggered when an inner form field has changed: updates the internal state accordingly - */ - const handleChanged = (field: string, value: string | number) => { - setData({ - ...data, - [field]: value - }); - }; - - /** - * Save the current product category to the API - */ - const handleSave = async (): Promise => { - try { - if (productCategory?.id) { - await ProductCategoryAPI.update(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_updated')); - } else { - await ProductCategoryAPI.create(data); - onSuccess(t('app.admin.store.product_category_modal.successfully_created')); - } - } catch (e) { - if (productCategory?.id) { - onError(t('app.admin.store.product_category_modal.unable_to_update') + e); - } else { - onError(t('app.admin.store.product_category_modal.unable_to_create') + e); - } - } - }; - - /** - * Check if the form is valid (not empty, url valid slug) - */ - const isPreventedSaveProductCategory = (): boolean => { - return !data.name || !data.slug || !checkIfValidURLSlug(data.slug); - }; - - return ( - - - - ); -}; diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index cd21250d7..c2a672e02 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -86,7 +86,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm { state: 'app.admin.store', linkText: 'app.public.common.manage_the_store', - linkIcon: 'cogs', + linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] }, $scope.$root.modules.trainings && { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 14723c579..48fe7e9a3 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -85,6 +85,8 @@ @import "modules/settings/check-list-setting"; @import "modules/settings/user-validation-setting"; @import "modules/socials/fab-socials"; +@import "modules/store/manage-product-category"; +@import "modules/store/product-categories"; @import "modules/subscriptions/free-extend-modal"; @import "modules/subscriptions/renew-modal"; @import "modules/supporting-documents/supporting-documents-files"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index 5f70a2d3a..af0df298b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -46,4 +46,7 @@ &--icon { margin-right: 0.5em; } + &--icon-only { + display: flex; + } } diff --git a/app/frontend/src/stylesheets/modules/base/fab-modal.scss b/app/frontend/src/stylesheets/modules/base/fab-modal.scss index 4e5d538b3..890c98c3b 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-modal.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-modal.scss @@ -81,6 +81,12 @@ position: relative; padding: 15px; + .subtitle { + margin-bottom: 3.2rem; + @include title-base; + color: var(--gray-hard-darkest); + } + form { display: flex; flex-direction: column; diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 59ac7a110..d8c897b48 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -64,6 +64,7 @@ border: 1px solid var(--gray-soft-dark); border-radius: var(--border-radius); transition: border-color ease-in-out 0.15s; + font-weight: 400; .icon, .addon { diff --git a/app/frontend/src/stylesheets/modules/store/manage-product-category.scss b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss new file mode 100644 index 000000000..41a61d564 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/manage-product-category.scss @@ -0,0 +1,3 @@ +.manage-product-category { + +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss new file mode 100644 index 000000000..75963f5f2 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -0,0 +1,97 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} + +.product-categories { + max-width: 1300px; + margin: 0 auto; + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest); + } + } + + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } + } + + &-list { + & > *:not(:last-of-type) { + margin-bottom: 1.6rem; + } + } + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .action { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-button {background: var(--gray-hard-darkest) } + .delete-button {background: var(--error) } + } + } + + .draghandle { + @include btn; + cursor: grab; + } + } +} \ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 545fbbe1c..ba8e8aad5 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1894,17 +1894,18 @@ en: title: "Documentation" content: "Click here to access the API online documentation." store: - manage_the_store: "Manage the Store Fablab" + manage_the_store: "Manage the Store" settings: "Settings" all_products: "All products" - categories_of_store: "Categories of store" + categories_of_store: "Store's categories" the_orders: "Orders" product_categories: - create_a_product_category: "Create a category" - the_categories: "Categories" + title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." - successfully_deleted: "The category has been successfully deleted" - unable_to_delete: "Unable to delete the category: " + manage_product_category: + create: "Create a product category" + update: "Modify the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "The new category has been created." unable_to_create: "Unable to create the category: " @@ -1912,8 +1913,12 @@ en: unable_to_update: "Unable to modify the category: " new_product_category: "Create a category" edit_product_category: "Modify a category" - save: "Save" product_category_form: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Unable to delete the category: " + success: "The category has been successfully deleted" + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 93ea64a4e..ff0d0982d 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1900,20 +1900,25 @@ fr: categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" product_categories: - create_a_product_category: "Créer une catégorie" - the_categories: "Les catégories" - info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveux maximum, vous pouvez les agancer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." - successfully_deleted: "La catégorie a bien été supprimé" - unable_to_delete: "Impossible de supprimer the category: " + title: "Les catégories" + info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." + manage_product_category: + create: "Create a product category" + update: "Update the product category" + delete: "Delete the product category" product_category_modal: successfully_created: "La catégorie a bien été créée." unable_to_create: "Impossible de créer la catégorie : " successfully_updated: "La nouvelle catégorie a bien été mise à jour." unable_to_update: "Impossible de modifier la catégorie : " new_product_category: "Créer une catégorie" - edit_product_category: "Modifier la catéogirie" - save: "Sauvgarder" + edit_product_category: "Modifier la catégorie" product_category_form: name: "Nom de la catégorie" slug: "Nom de l'URL" select_parent_product_category: "Choisir une catégorie parent (N1)" + delete: + confirm: "Do you really want to delete this product category?" + error: "Impossible de supprimer the catégorie : " + success: "La catégorie a bien été supprimée" + save: "Enregistrer" From bf1700e43a60b638efa3e320cab7b7604993dce2 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 18 Jul 2022 14:57:33 +0200 Subject: [PATCH 15/38] Convert product category form to RHF --- .../store/manage-product-category.tsx | 2 +- .../components/store/product-categories.tsx | 3 +- .../store/product-category-form.tsx | 35 ++++++++++++++----- config/locales/app.admin.en.yml | 12 ++++--- config/locales/app.admin.fr.yml | 12 ++++--- 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/manage-product-category.tsx index 52dcb1557..28b99b63f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/manage-product-category.tsx @@ -15,7 +15,7 @@ interface ManageProductCategoryProps { /** * This component shows a button. - * When clicked, we show a modal dialog allowing to fill the parameters of a product category (create new or update existing). + * When clicked, we show a modal dialog allowing to fill the parameters of a product category. */ export const ManageProductCategory: React.FC = ({ productCategories, productCategory, action, onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/product-categories.tsx index 17e72baaf..977bbe01f 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/product-categories.tsx @@ -18,7 +18,8 @@ interface ProductCategoriesProps { } /** - * This component shows a Tree list of all Product's Categories + * This component shows a list of all product categories and offer to manager them + * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/product-category-form.tsx index 339765be7..9f1f31b54 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/product-category-form.tsx @@ -29,7 +29,7 @@ interface ProductCategoryFormProps { export const ProductCategoryForm: React.FC = ({ action, productCategories, productCategory, onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { register, watch, setValue, control, handleSubmit } = useForm({ defaultValues: { ...productCategory } }); + const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie const parents = productCategories.filter(c => !c.parent_id); @@ -53,15 +53,26 @@ export const ProductCategoryForm: React.FC = ({ action }); return () => subscription.unsubscribe(); }, [watch]); + // Check slug pattern + // Only lowercase alphanumeric groups of characters separated by an hyphen + const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/g; // Form submit const onSubmit: SubmitHandler = (category: ProductCategory) => { switch (action) { case 'create': - console.log('create:', category); + ProductCategoryAPI.create(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.create.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.create.error') + error); + }); break; case 'update': - console.log('update:', category); + ProductCategoryAPI.update(category).then(() => { + onSuccess(t('app.admin.store.product_category_form.update.success')); + }).catch((error) => { + onError(t('app.admin.store.product_category_form.update.error') + error); + }); break; case 'delete': ProductCategoryAPI.destroy(category.id).then(() => { @@ -84,13 +95,21 @@ export const ProductCategoryForm: React.FC = ({ action : <> + register={register} + rules={{ required: `${t('app.admin.store.product_category_form.required')}` }} + formState={formState} + label={t('app.admin.store.product_category_form.name')} + defaultValue={productCategory?.name || ''} /> Date: Wed, 20 Jul 2022 08:53:54 +0200 Subject: [PATCH 16/38] Add subfolder in store --- .../manage-product-category.tsx | 6 +++--- .../product-categories-tree.tsx} | 12 ++++++------ .../{ => categories}/product-categories.tsx | 18 +++++++++--------- .../{ => categories}/product-category-form.tsx | 12 ++++++------ .../modules/store/product-categories.scss | 2 +- 5 files changed, 25 insertions(+), 25 deletions(-) rename app/frontend/src/javascript/components/store/{ => categories}/manage-product-category.tsx (93%) rename app/frontend/src/javascript/components/store/{product-categories-list.tsx => categories/product-categories-tree.tsx} (75%) rename app/frontend/src/javascript/components/store/{ => categories}/product-categories.tsx (80%) rename app/frontend/src/javascript/components/store/{ => categories}/product-category-form.tsx (93%) diff --git a/app/frontend/src/javascript/components/store/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/manage-product-category.tsx rename to app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 28b99b63f..068c5294f 100644 --- a/app/frontend/src/javascript/components/store/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabModal, ModalSize } from '../base/fab-modal'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabModal, ModalSize } from '../../base/fab-modal'; import { ProductCategoryForm } from './product-category-form'; interface ManageProductCategoryProps { diff --git a/app/frontend/src/javascript/components/store/product-categories-list.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx similarity index 75% rename from app/frontend/src/javascript/components/store/product-categories-list.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 0d0baf74e..132a2328c 100644 --- a/app/frontend/src/javascript/components/store/product-categories-list.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import { ProductCategory } from '../../models/product-category'; +import { ProductCategory } from '../../../models/product-category'; import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../base/fab-button'; +import { FabButton } from '../../base/fab-button'; import { ManageProductCategory } from './manage-product-category'; -interface ProductCategoriesListProps { +interface ProductCategoriesTreeProps { productCategories: Array, onSuccess: (message: string) => void, onError: (message: string) => void, } /** - * This component shows a Tree list of all Product's Categories + * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesList: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { return ( -
+
{productCategories.map((category) => (
diff --git a/app/frontend/src/javascript/components/store/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx similarity index 80% rename from app/frontend/src/javascript/components/store/product-categories.tsx rename to app/frontend/src/javascript/components/store/categories/product-categories.tsx index 977bbe01f..0900f56fa 100644 --- a/app/frontend/src/javascript/components/store/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProductCategory } from '../../models/product-category'; -import ProductCategoryAPI from '../../api/product-category'; +import { ProductCategory } from '../../../models/product-category'; +import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; -import { ProductCategoriesList } from './product-categories-list'; -import { FabAlert } from '../base/fab-alert'; -import { HtmlTranslate } from '../base/html-translate'; -import { IApplication } from '../../models/application'; -import { Loader } from '../base/loader'; +import { ProductCategoriesTree } from './product-categories-tree'; +import { FabAlert } from '../../base/fab-alert'; +import { HtmlTranslate } from '../../base/html-translate'; +import { IApplication } from '../../../models/application'; +import { Loader } from '../../base/loader'; import { react2angular } from 'react2angular'; declare const Application: IApplication; @@ -18,7 +18,7 @@ interface ProductCategoriesProps { } /** - * This component shows a list of all product categories and offer to manager them + * This component shows a tree list of all product categories and offer to manager them * by creating, deleting, modifying and reordering each product categories. */ const ProductCategories: React.FC = ({ onSuccess, onError }) => { @@ -61,7 +61,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro -
diff --git a/app/frontend/src/javascript/components/store/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/product-category-form.tsx rename to app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 9f1f31b54..87136b03e 100644 --- a/app/frontend/src/javascript/components/store/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -2,12 +2,12 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm, SubmitHandler } from 'react-hook-form'; import slugify from 'slugify'; -import { FormInput } from '../form/form-input'; -import { FormSelect } from '../form/form-select'; -import { ProductCategory } from '../../models/product-category'; -import { FabButton } from '../base/fab-button'; -import { FabAlert } from '../base/fab-alert'; -import ProductCategoryAPI from '../../api/product-category'; +import { FormInput } from '../../form/form-input'; +import { FormSelect } from '../../form/form-select'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { FabAlert } from '../../base/fab-alert'; +import ProductCategoryAPI from '../../../api/product-category'; interface ProductCategoryFormProps { action: 'create' | 'update' | 'delete', diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 75963f5f2..3aab16990 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -39,7 +39,7 @@ } } - &-list { + &-tree { & > *:not(:last-of-type) { margin-bottom: 1.6rem; } From b53efc985045d5cf5bc9ba20edbe81b668ad3ae5 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 20 Jul 2022 11:54:46 +0200 Subject: [PATCH 17/38] change top position of product category to 0 --- app/models/product_category.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/product_category.rb b/app/models/product_category.rb index a17149171..8feb8afda 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,5 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id - acts_as_list scope: :parent + acts_as_list scope: :parent, top_of_list: 0 end From 69e2b3e1117c76518c6183937f9bca1000cb3644 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 15:06:46 +0200 Subject: [PATCH 18/38] Product model/controller --- app/controllers/api/products_controller.rb | 52 +++++++++++++++++++ app/models/product.rb | 3 ++ app/policies/product_policy.rb | 16 ++++++ app/services/product_service.rb | 8 +++ app/views/api/products/_product.json.jbuilder | 3 ++ app/views/api/products/create.json.jbuilder | 3 ++ app/views/api/products/index.json.jbuilder | 5 ++ app/views/api/products/show.json.jbuilder | 3 ++ app/views/api/products/update.json.jbuilder | 3 ++ config/routes.rb | 2 + db/migrate/20220712153708_create_products.rb | 19 +++++++ ...60137_create_join_table_product_machine.rb | 8 +++ db/schema.rb | 23 ++++++++ 13 files changed, 148 insertions(+) create mode 100644 app/controllers/api/products_controller.rb create mode 100644 app/models/product.rb create mode 100644 app/policies/product_policy.rb create mode 100644 app/services/product_service.rb create mode 100644 app/views/api/products/_product.json.jbuilder create mode 100644 app/views/api/products/create.json.jbuilder create mode 100644 app/views/api/products/index.json.jbuilder create mode 100644 app/views/api/products/show.json.jbuilder create mode 100644 app/views/api/products/update.json.jbuilder create mode 100644 db/migrate/20220712153708_create_products.rb create mode 100644 db/migrate/20220712160137_create_join_table_product_machine.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb new file mode 100644 index 000000000..b48777b5c --- /dev/null +++ b/app/controllers/api/products_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# API Controller for resources of type Product +# Products are used in store +class API::ProductsController < API::ApiController + before_action :authenticate_user!, except: %i[index show] + before_action :set_product, only: %i[update destroy] + + def index + @products = ProductService.list + end + + def show; end + + def create + authorize Product + @product = Product.new(product_params) + if @product.save + render status: :created + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def update + authorize @product + + if @product.update(product_params) + render status: :ok + else + render json: @product.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + authorize @product + @product.destroy + head :no_content + end + + private + + def set_product + @product = Product.find(params[:id]) + end + + 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) + end +end diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 000000000..48d439822 --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,3 @@ +class Product < ApplicationRecord + belongs_to :product_category +end diff --git a/app/policies/product_policy.rb b/app/policies/product_policy.rb new file mode 100644 index 000000000..f64026b79 --- /dev/null +++ b/app/policies/product_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Check the access policies for API::ProductsController +class ProductPolicy < ApplicationPolicy + def create? + user.privileged? + end + + def update? + user.privileged? + end + + def destroy? + user.privileged? + end +end diff --git a/app/services/product_service.rb b/app/services/product_service.rb new file mode 100644 index 000000000..d31f61ae8 --- /dev/null +++ b/app/services/product_service.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Provides methods for Product +class ProductService + def self.list + Product.all + end +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder new file mode 100644 index 000000000..b18ee0374 --- /dev/null +++ b/app/views/api/products/_product.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold diff --git a/app/views/api/products/create.json.jbuilder b/app/views/api/products/create.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder new file mode 100644 index 000000000..bc58aeb30 --- /dev/null +++ b/app/views/api/products/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.array! @products do |product| + json.partial! 'api/products/product', product: product +end diff --git a/app/views/api/products/show.json.jbuilder b/app/views/api/products/show.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/app/views/api/products/update.json.jbuilder b/app/views/api/products/update.json.jbuilder new file mode 100644 index 000000000..867abc99b --- /dev/null +++ b/app/views/api/products/update.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! 'api/products/product', product: @product diff --git a/config/routes.rb b/config/routes.rb index 6abcf4dca..e9dc37f22 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -154,6 +154,8 @@ Rails.application.routes.draw do patch 'position', on: :member end + resources :products + # for admin resources :trainings do get :availabilities, on: :member diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb new file mode 100644 index 000000000..154e1a896 --- /dev/null +++ b/db/migrate/20220712153708_create_products.rb @@ -0,0 +1,19 @@ +class CreateProducts < ActiveRecord::Migration[5.2] + def change + create_table :products do |t| + t.string :name + t.string :slug + t.string :sku + t.text :description + t.boolean :is_active, default: false + t.belongs_to :product_category, foreign_key: true + t.integer :amount + t.integer :quantity_min + t.jsonb :stock, default: { internal: 0, external: 0 } + t.boolean :low_stock_alert, default: false + t.integer :low_stock_threshold + + t.timestamps + end + end +end diff --git a/db/migrate/20220712160137_create_join_table_product_machine.rb b/db/migrate/20220712160137_create_join_table_product_machine.rb new file mode 100644 index 000000000..874005fd0 --- /dev/null +++ b/db/migrate/20220712160137_create_join_table_product_machine.rb @@ -0,0 +1,8 @@ +class CreateJoinTableProductMachine < ActiveRecord::Migration[5.2] + def change + create_join_table :products, :machines do |t| + # t.index [:product_id, :machine_id] + # t.index [:machine_id, :product_id] + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e65091513..1222e9bcf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -367,6 +367,11 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["machine_id"], name: "index_machines_availabilities_on_machine_id" end + create_table "machines_products", id: false, force: :cascade do |t| + t.bigint "product_id", null: false + t.bigint "machine_id", null: false + end + create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" t.string "attached_object_type" @@ -591,6 +596,23 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "products", force: :cascade do |t| + t.string "name" + t.string "slug" + t.string "sku" + t.text "description" + t.boolean "is_active", default: false + t.bigint "product_category_id" + t.integer "amount" + t.integer "quantity_min" + t.jsonb "stock", default: {"external"=>0, "internal"=>0} + t.boolean "low_stock_alert", default: false + t.integer "low_stock_threshold" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_category_id"], name: "index_products_on_product_category_id" + end + create_table "profile_custom_fields", force: :cascade do |t| t.string "label" t.boolean "required", default: false @@ -1112,6 +1134,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects" add_foreign_key "project_users", "users" From 6b805f15f1fa49f29bb4e0af7f125f4e34de0d7c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 13 Jul 2022 19:34:38 +0200 Subject: [PATCH 19/38] products page in front --- app/frontend/src/javascript/api/product.ts | 30 ++++++++ .../components/store/products-list.tsx | 50 ++++++++++++ .../javascript/components/store/products.tsx | 77 +++++++++++++++++++ .../src/javascript/controllers/admin/store.js | 30 +++++++- .../src/javascript/controllers/main_nav.js | 2 +- app/frontend/src/javascript/models/product.ts | 24 ++++++ app/frontend/src/javascript/router.js | 17 ++++ app/frontend/templates/admin/store/index.html | 8 +- .../templates/admin/store/products.html | 2 +- config/locales/app.admin.en.yml | 7 +- config/locales/app.admin.fr.yml | 7 +- 11 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 app/frontend/src/javascript/api/product.ts create mode 100644 app/frontend/src/javascript/components/store/products-list.tsx create mode 100644 app/frontend/src/javascript/components/store/products.tsx create mode 100644 app/frontend/src/javascript/models/product.ts diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts new file mode 100644 index 000000000..edb434c95 --- /dev/null +++ b/app/frontend/src/javascript/api/product.ts @@ -0,0 +1,30 @@ +import apiClient from './clients/api-client'; +import { AxiosResponse } from 'axios'; +import { Product } from '../models/product'; + +export default class ProductAPI { + static async index (): Promise> { + const res: AxiosResponse> = await apiClient.get('/api/products'); + return res?.data; + } + + static async get (id: number): Promise { + const res: AxiosResponse = await apiClient.get(`/api/products/${id}`); + return res?.data; + } + + static async create (product: Product): Promise { + const res: AxiosResponse = await apiClient.post('/api/products', { product }); + return res?.data; + } + + static async update (product: Product): Promise { + const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, { product }); + return res?.data; + } + + static async destroy (productId: number): Promise { + const res: AxiosResponse = await apiClient.delete(`/api/products/${productId}`); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/components/store/products-list.tsx b/app/frontend/src/javascript/components/store/products-list.tsx new file mode 100644 index 000000000..60f05cd96 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products-list.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { FabButton } from '../base/fab-button'; +import { Product } from '../../models/product'; + +interface ProductsListProps { + products: Array, + onEdit: (product: Product) => void, + onDelete: (productId: number) => void, +} + +/** + * This component shows a list of all Products + */ +export const ProductsList: React.FC = ({ products, onEdit, onDelete }) => { + /** + * Init the process of editing the given product + */ + const editProduct = (product: Product): () => void => { + return (): void => { + onEdit(product); + }; + }; + + /** + * Init the process of delete the given product + */ + const deleteProduct = (productId: number): () => void => { + return (): void => { + onDelete(productId); + }; + }; + + return ( +
+ {products.map((product) => ( +
+ {product.name} +
+ + + + + + +
+
+ ))} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx new file mode 100644 index 000000000..09c7e0912 --- /dev/null +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -0,0 +1,77 @@ +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 { ProductsList } from './products-list'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface ProductsProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component shows all Products and filter + */ +const Products: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [products, setProducts] = useState>([]); + const [product, setProduct] = useState(null); + + useEffect(() => { + ProductAPI.index().then(data => { + setProducts(data); + }); + }, []); + + /** + * Open edit the product modal + */ + const editProduct = (product: Product) => { + setProduct(product); + }; + + /** + * Delete a product + */ + const deleteProduct = async (productId: number): Promise => { + try { + await ProductAPI.destroy(productId); + const data = await ProductAPI.index(); + setProducts(data); + onSuccess(t('app.admin.store.products.successfully_deleted')); + } catch (e) { + onError(t('app.admin.store.products.unable_to_delete') + e); + } + }; + + return ( +
+

{t('app.admin.store.products.all_products')}

+ {t('app.admin.store.products.create_a_product')} + +
+ ); +}; + +const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/controllers/admin/store.js b/app/frontend/src/javascript/controllers/admin/store.js index 76cac12da..f46365752 100644 --- a/app/frontend/src/javascript/controllers/admin/store.js +++ b/app/frontend/src/javascript/controllers/admin/store.js @@ -4,9 +4,35 @@ */ 'use strict'; -Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', - function ($scope, CSRF, growl) { +Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', + function ($scope, CSRF, growl, $state) { + /* PRIVATE SCOPE */ + // Map of tab state and index + const TABS = { + 'app.admin.store.settings': 0, + 'app.admin.store.products': 1, + 'app.admin.store.categories': 2, + 'app.admin.store.orders': 3 + }; + /* PUBLIC SCOPE */ + // default tab: products + $scope.tabs = { + active: TABS[$state.current.name] + }; + + /** + * Callback triggered in click tab + */ + $scope.selectTab = () => { + setTimeout(function () { + const currentTab = _.keys(TABS)[$scope.tabs.active]; + if (currentTab !== $state.current.name) { + $state.go(currentTab, { location: true, notify: false, reload: false }); + } + }); + }; + /** * Callback triggered in case of error */ diff --git a/app/frontend/src/javascript/controllers/main_nav.js b/app/frontend/src/javascript/controllers/main_nav.js index c2a672e02..ead43fda0 100644 --- a/app/frontend/src/javascript/controllers/main_nav.js +++ b/app/frontend/src/javascript/controllers/main_nav.js @@ -84,7 +84,7 @@ Application.Controllers.controller('MainNavController', ['$scope', 'settingsProm authorizedRoles: ['admin', 'manager'] }, { - state: 'app.admin.store', + state: 'app.admin.store.products', linkText: 'app.public.common.manage_the_store', linkIcon: 'cart-plus', authorizedRoles: ['admin', 'manager'] diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts new file mode 100644 index 000000000..b5818c1f4 --- /dev/null +++ b/app/frontend/src/javascript/models/product.ts @@ -0,0 +1,24 @@ +export enum StockType { + internal = 'internal', + external = 'external' +} + +export interface Stock { + internal: number, + external: number, +} + +export interface Product { + id: number, + name: string, + slug: string, + sku: string, + description: string, + is_active: boolean, + product_category_id: number, + amount: number, + quantity_min: number, + stock: Stock, + low_stock_alert: boolean, + low_stock_threshold: number, +} diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index d86e624a6..da074335a 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1105,6 +1105,7 @@ angular.module('application.router', ['ui.router']) }) .state('app.admin.store', { + abstract: true, url: '/admin/store', views: { 'main@': { @@ -1114,6 +1115,22 @@ angular.module('application.router', ['ui.router']) } }) + .state('app.admin.store.settings', { + url: '/settings' + }) + + .state('app.admin.store.products', { + url: '/products' + }) + + .state('app.admin.store.categories', { + url: '/categories' + }) + + .state('app.admin.store.orders', { + url: '/orders' + }) + // OpenAPI Clients .state('app.admin.open_api_clients', { url: '/open_api_clients', diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index cf591b0ec..0b7557e96 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -20,19 +20,19 @@
- + - + - + - + diff --git a/app/frontend/templates/admin/store/products.html b/app/frontend/templates/admin/store/products.html index c4db68bf6..e37bcce4f 100644 --- a/app/frontend/templates/admin/store/products.html +++ b/app/frontend/templates/admin/store/products.html @@ -1 +1 @@ -

Products page

+ diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d9c24536e..dede5aae3 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1925,4 +1925,9 @@ en: success: "The category has been successfully deleted" save: "Save" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "All products" + create_a_product: "Create a product" + successfully_deleted: "The product has been successfully deleted" + unable_to_delete: "Unable to delete the product: " diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2bbcea278..991a429e4 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1925,4 +1925,9 @@ fr: success: "La catégorie a bien été supprimée" save: "Enregistrer" required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" \ No newline at end of file + slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + products: + all_products: "Tous les produits" + create_a_product: "Créer un produit" + successfully_deleted: "Le produit a bien été supprimé" + unable_to_delete: "Impossible de supprimer le produit: " From 272cbf165c2febf986badc0fb13419be3ee2bec4 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 22 Jul 2022 18:48:28 +0200 Subject: [PATCH 20/38] create/edit product form --- app/controllers/api/products_controller.rb | 11 +- .../components/form/form-check-list.tsx | 99 +++++++++ .../components/store/edit-product.tsx | 56 +++++ .../components/store/new-product.tsx | 58 +++++ .../components/store/product-form.tsx | 199 ++++++++++++++++++ .../javascript/components/store/products.tsx | 16 +- .../controllers/admin/store_products.js | 47 +++++ app/frontend/src/javascript/models/product.ts | 9 +- app/frontend/src/javascript/router.js | 52 ++++- app/frontend/src/stylesheets/application.scss | 1 + .../modules/form/form-check-list.scss | 17 ++ .../templates/admin/store/product_edit.html | 35 +++ .../templates/admin/store/product_new.html | 35 +++ app/models/machine.rb | 1 + app/models/product.rb | 7 + app/views/api/products/_product.json.jbuilder | 3 +- config/locales/app.admin.en.yml | 23 ++ config/locales/app.admin.fr.yml | 23 ++ config/locales/app.shared.en.yml | 2 + config/locales/app.shared.fr.yml | 2 + db/migrate/20220712153708_create_products.rb | 2 + 21 files changed, 677 insertions(+), 21 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-check-list.tsx create mode 100644 app/frontend/src/javascript/components/store/edit-product.tsx create mode 100644 app/frontend/src/javascript/components/store/new-product.tsx create mode 100644 app/frontend/src/javascript/components/store/product-form.tsx create mode 100644 app/frontend/src/javascript/controllers/admin/store_products.js create mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/templates/admin/store/product_edit.html create mode 100644 app/frontend/templates/admin/store/product_new.html diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b48777b5c..e411ce090 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -4,7 +4,7 @@ # Products are used in store class API::ProductsController < API::ApiController before_action :authenticate_user!, except: %i[index show] - before_action :set_product, only: %i[update destroy] + before_action :set_product, only: %i[show update destroy] def index @products = ProductService.list @@ -15,6 +15,8 @@ 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.save render status: :created else @@ -25,7 +27,10 @@ class API::ProductsController < API::ApiController def update authorize @product - if @product.update(product_params) + 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.update(product_parameters) render status: :ok else render json: @product.errors.full_messages, status: :unprocessable_entity @@ -47,6 +52,6 @@ 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) + :low_stock_alert, :low_stock_threshold, machine_ids: []) end end diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-check-list.tsx new file mode 100644 index 000000000..1299dcd56 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-check-list.tsx @@ -0,0 +1,99 @@ +import React, { BaseSyntheticEvent } from 'react'; +import { Controller, Path, FieldPathValue } from 'react-hook-form'; +import { FieldValues } from 'react-hook-form/dist/types/fields'; +import { FieldPath } from 'react-hook-form/dist/types/path'; +import { useTranslation } from 'react-i18next'; +import { UnpackNestedValue } from 'react-hook-form/dist/types'; +import { FormControlledComponent } from '../../models/form-component'; +import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item'; +import { FabButton } from '../base/fab-button'; + +/** + * Checklist Option format + */ +export type ChecklistOption = { value: TOptionValue, label: string }; + +interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { + defaultValue?: Array, + options: Array>, + onChange?: (values: Array) => void, +} + +/** + * This component is a template for an check list component to use within React Hook Form + */ +export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { + const { t } = useTranslation('shared'); + + /** + * Verify if the provided option is currently ticked + */ + const isChecked = (values: Array, option: ChecklistOption): boolean => { + return !!values?.includes(option.value); + }; + + /** + * Callback triggered when a checkbox is ticked or unticked. + */ + const toggleCheckbox = (option: ChecklistOption, values: Array = [], cb: (value: Array) => void) => { + return (event: BaseSyntheticEvent) => { + let newValues: Array = []; + if (event.target.checked) { + newValues = values.concat(option.value); + } else { + newValues = values.filter(v => v !== option.value); + } + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + /** + * Callback triggered to select all options + */ + const allSelect = (cb: (value: Array) => void) => { + return () => { + const newValues: Array = options.map(o => o.value); + cb(newValues); + if (typeof onChange === 'function') { + onChange(newValues); + } + }; + }; + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( + + } + control={control} + defaultValue={defaultValue as UnpackNestedValue>>} + rules={rules} + render={({ field: { onChange, value } }) => { + return ( + <> +
+ {options.map((option, k) => { + return ( +
+ + +
+ ); + })} +
+ {t('app.shared.form_check_list.select_all')} + + ); + }} /> +
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx new file mode 100644 index 000000000..62ccad66c --- /dev/null +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; +import { Product } from '../../models/product'; +import ProductAPI from '../../api/product'; + +declare const Application: IApplication; + +interface EditProductProps { + productId: number, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const [product, setProduct] = useState(); + + useEffect(() => { + ProductAPI.get(productId).then(data => { + setProduct(data); + }).catch(onError); + }, []); + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.edit_product.successfully_updated')); + window.location.href = '/#!/admin/store/products'; + }; + + if (product) { + return ( + + ); + } + return null; +}; + +const EditProductWrapper: React.FC = ({ productId, onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx new file mode 100644 index 000000000..e3f2ca4d2 --- /dev/null +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { react2angular } from 'react2angular'; +import { Loader } from '../base/loader'; +import { IApplication } from '../../models/application'; +import { ProductForm } from './product-form'; + +declare const Application: IApplication; + +interface NewProductProps { + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * This component show new product form + */ +const NewProduct: React.FC = ({ onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const product = { + id: undefined, + name: '', + slug: '', + sku: '', + description: '', + is_active: false, + quantity_min: 1, + stock: { + internal: 0, + external: 0 + }, + low_stock_alert: false, + machine_ids: [] + }; + + /** + * Success to save product and return to product list + */ + const saveProductSuccess = () => { + onSuccess(t('app.admin.store.new_product.successfully_created')); + window.location.href = '/#!/admin/store/products'; + }; + + return ( + + ); +}; + +const NewProductWrapper: React.FC = ({ onSuccess, onError }) => { + return ( + + + + ); +}; + +Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx new file mode 100644 index 000000000..e4198bbd5 --- /dev/null +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import slugify from 'slugify'; +import _ from 'lodash'; +import { HtmlTranslate } from '../base/html-translate'; +import { Product } from '../../models/product'; +import { FormInput } from '../form/form-input'; +import { FormSwitch } from '../form/form-switch'; +import { FormSelect } from '../form/form-select'; +import { FormCheckList } from '../form/form-check-list'; +import { FormRichText } from '../form/form-rich-text'; +import { FabButton } from '../base/fab-button'; +import { FabAlert } from '../base/fab-alert'; +import ProductCategoryAPI from '../../api/product-category'; +import MachineAPI from '../../api/machine'; +import ProductAPI from '../../api/product'; + +interface ProductFormProps { + product: Product, + title: string, + onSuccess: (product: Product) => void, + onError: (message: string) => void, +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: number, label: string }; + +/** + * Option format, expected by checklist + */ +type checklistOption = { value: number, label: string }; + +/** + * Form component to create or update a product + */ +export const ProductForm: React.FC = ({ product, title, onSuccess, onError }) => { + const { t } = useTranslation('admin'); + + const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); + const [productCategories, setProductCategories] = useState([]); + const [machines, setMachines] = useState([]); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(buildSelectOptions(data)); + }).catch(onError); + MachineAPI.index({ disabled: false }).then(data => { + setMachines(buildChecklistOptions(data)); + }).catch(onError); + }, []); + + /** + * Convert the provided array of items to the react-select format + */ + const buildSelectOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Convert the provided array of items to the checklist format + */ + const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { + return items.map(t => { + return { value: t.id, label: t.name }; + }); + }; + + /** + * Callback triggered when the name has changed. + */ + const handleNameChange = (event: React.ChangeEvent): void => { + const name = event.target.value; + const slug = slugify(name, { lower: true, strict: true }); + setValue('slug', slug); + }; + + /** + * Callback triggered when is active price has changed. + */ + const toggleIsActivePrice = (value: boolean) => { + if (!value) { + setValue('amount', null); + } + setIsActivePrice(value); + }; + + /** + * Callback triggered when the form is submitted: process with the product creation or update. + */ + const onSubmit = (event: React.FormEvent) => { + return handleSubmit((data: Product) => { + saveProduct(data); + })(event); + }; + + /** + * Call product creation or update api + */ + const saveProduct = (data: Product) => { + if (product.id) { + ProductAPI.update(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } else { + ProductAPI.create(data).then((res) => { + reset(res); + onSuccess(res); + }).catch(onError); + } + }; + + return ( + <> +

{title}

+ {t('app.admin.store.product_form.save')} +
+ + + + +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ + {isActivePrice &&
+ + +
} +

{t('app.admin.store.product_form.assigning_category')}

+ + + + +

{t('app.admin.store.product_form.assigning_machines')}

+ + + + +

{t('app.admin.store.product_form.product_description')}

+ + + + +
+
+ {t('app.admin.store.product_form.save')} +
+ + + ); +}; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 09c7e0912..7f60082a3 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -1,10 +1,8 @@ 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 { ProductsList } from './products-list'; import { Product } from '../../models/product'; @@ -24,7 +22,6 @@ const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); const [products, setProducts] = useState>([]); - const [product, setProduct] = useState(null); useEffect(() => { ProductAPI.index().then(data => { @@ -33,10 +30,10 @@ const Products: React.FC = ({ onSuccess, onError }) => { }, []); /** - * Open edit the product modal + * Goto edit product page */ const editProduct = (product: Product) => { - setProduct(product); + window.location.href = `/#!/admin/store/products/${product.id}/edit`; }; /** @@ -53,10 +50,17 @@ const Products: React.FC = ({ onSuccess, onError }) => { } }; + /** + * Goto new product page + */ + const newProduct = (): void => { + window.location.href = '/#!/admin/store/products/new'; + }; + return (

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} + {t('app.admin.store.products.create_a_product')} { + growl.error(message); + }; + + /** + * Callback triggered in case of success + */ + $scope.onSuccess = (message) => { + growl.success(message); + }; + + /** + * Click Callback triggered in case of back products list + */ + $scope.backProductsList = () => { + $state.go('app.admin.store.products'); + }; + + /* PRIVATE SCOPE */ + + /** + * Kind of constructor: these actions will be realized first when the controller is loaded + */ + const initialize = function () { + // set the authenticity tokens in the forms + CSRF.setMetaTags(); + }; + + // init the controller (call at the end !) + return initialize(); + } + +]); diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index b5818c1f4..9038dbd5c 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -15,10 +15,11 @@ export interface Product { sku: string, description: string, is_active: boolean, - product_category_id: number, - amount: number, - quantity_min: number, + product_category_id?: number, + amount?: number, + quantity_min?: number, stock: Stock, low_stock_alert: boolean, - low_stock_threshold: number, + low_stock_threshold?: number, + machine_ids: number[], } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index da074335a..df21a17b5 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -1106,7 +1106,11 @@ angular.module('application.router', ['ui.router']) .state('app.admin.store', { abstract: true, - url: '/admin/store', + url: '/admin/store' + }) + + .state('app.admin.store.settings', { + url: '/settings', views: { 'main@': { templateUrl: '/admin/store/index.html', @@ -1115,20 +1119,54 @@ angular.module('application.router', ['ui.router']) } }) - .state('app.admin.store.settings', { - url: '/settings' + .state('app.admin.store.products', { + url: '/products', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) - .state('app.admin.store.products', { - url: '/products' + .state('app.admin.store.products_new', { + url: '/products/new', + views: { + 'main@': { + templateUrl: '/admin/store/product_new.html', + controller: 'AdminStoreProductController' + } + } + }) + + .state('app.admin.store.products_edit', { + url: '/products/:id/edit', + views: { + 'main@': { + templateUrl: '/admin/store/product_edit.html', + controller: 'AdminStoreProductController' + } + } }) .state('app.admin.store.categories', { - url: '/categories' + url: '/categories', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) .state('app.admin.store.orders', { - url: '/orders' + url: '/orders', + views: { + 'main@': { + templateUrl: '/admin/store/index.html', + controller: 'AdminStoreController' + } + } }) // OpenAPI Clients diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 48fe7e9a3..b3543e447 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,6 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; +@import "modules/form/form-check-list"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss new file mode 100644 index 000000000..f2b255c7d --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-check-list.scss @@ -0,0 +1,17 @@ +.form-check-list { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + } + + .checklist-item { + flex: 0 0 33.333333%; + } +} diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html new file mode 100644 index 000000000..0bdf21ca9 --- /dev/null +++ b/app/frontend/templates/admin/store/product_edit.html @@ -0,0 +1,35 @@ +
+ +
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html new file mode 100644 index 000000000..eb61f3019 --- /dev/null +++ b/app/frontend/templates/admin/store/product_new.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+
+
+
+

{{ 'app.admin.store.manage_the_store' }}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
diff --git a/app/models/machine.rb b/app/models/machine.rb index e7d0eadde..da118eddf 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -29,6 +29,7 @@ class Machine < ApplicationRecord has_one :payment_gateway_object, as: :item + has_and_belongs_to_many :products after_create :create_statistic_subtype after_create :create_machine_prices diff --git a/app/models/product.rb b/app/models/product.rb index 48d439822..29d0e1697 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +# Product is a model for the merchandise hold information of product in store class Product < ApplicationRecord belongs_to :product_category + + has_and_belongs_to_many :machines + + validates_numericality_of :amount, greater_than: 0, allow_nil: true end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index b18ee0374..624f8e45d 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,3 +1,4 @@ # frozen_string_literal: true -json.extract! product, :id, :name, :slug, :sku, :description, :is_active, :product_category_id, :amount, :quantity_min, :stock, :low_stock_alert, :low_stock_threshold +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? diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index dede5aae3..95d249d95 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1899,6 +1899,7 @@ en: all_products: "All products" categories_of_store: "Store's categories" the_orders: "Orders" + back_products_list: "Back to products list" product_categories: title: "Categories" info: "Information:
Find below all the categories created. The categories are arranged on two levels maximum, you can arrange them with a drag and drop. The order of the categories will be identical on the public view and the list below. Please note that you can delete a category or a sub-category even if they are associated with products. The latter will be left without categories. If you delete a category that contains sub-categories, the latter will also be deleted. Make sure that your categories are well arranged and save your choice." @@ -1931,3 +1932,25 @@ en: create_a_product: "Create a product" successfully_deleted: "The product has been successfully deleted" unable_to_delete: "Unable to delete the product: " + new_product: + add_a_new_product: "Add a new product" + successfully_created: "The new product has been created." + edit_product: + successfully_updated: "The product has been updated." + product_form: + name: "Name of product" + sku: "Reference product (SKU)" + slug: "Name of URL" + is_show_in_store: "Available in the store" + is_active_price: "Activate the price" + price_and_rule_of_selling_product: "Price and rule for selling the product" + price: "Price of product" + quantity_min: "Minimum number of items for the shopping cart" + linking_product_to_category: "Linking this product to an existing category" + assigning_category: "Assigning a category" + assigning_category_info: "Information
You can only declare one category per product. If you assign this product to a sub-category, it will automatically be assigned to its parent category as well." + assigning_machines: "Assigning machines" + assigning_machines_info: "Information
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.
The machines selected below will be linked to the product." + product_description: "Product description" + product_description_info: "Information
This product description will be present in the product sheet. You have a few editorial styles at your disposal to create the product sheet." + save: "Save" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 991a429e4..fabc04045 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1899,6 +1899,7 @@ fr: all_products: "Tous les produits" categories_of_store: "Les catégories de la boutique" the_orders: "Les commandes" + back_products_list: "Retrounez à la liste" product_categories: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." @@ -1931,3 +1932,25 @@ fr: create_a_product: "Créer un produit" successfully_deleted: "Le produit a bien été supprimé" unable_to_delete: "Impossible de supprimer le produit: " + new_product: + add_a_new_product: "Ajouter un nouveau produit" + successfully_created: "Le produit a bien été créée." + edit_product: + successfully_updated: "Le produit a bien été mise à jour." + product_form: + name: "Nom de produit" + sku: "Référence produit (SKU)" + slug: "Nom de l'URL" + is_show_in_store: "Visible dans la boutique" + is_active_price: "Activer le prix" + price_and_rule_of_selling_product: "Prix et règle de vente du produit" + price: "Prix du produit" + quantity_min: "Nombre d'article minimum pour la mise au panier" + linking_product_to_category: "Lier ce product à une catégorie exisante" + assigning_category: "Attribuer à une catégorie" + assigning_category_info: "Information
Vous ne pouvez déclarer qu'une catégorie par produit. Si vous attribuez ce produit à une sous catégorie, il sera attribué automatiquement aussi à sa catégorie parent." + assigning_machines: "Attribuer aux machines" + assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." + product_description: "Description du produit" + product_description_info: "Information
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." + save: "Enregistrer" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 34724b6d0..14ff7ba51 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,3 +550,5 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" + form_check_list: + select_all: "Select all" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index d39a36486..0827df858 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,3 +550,5 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" + form_check_list: + select_all: "Tout sélectionner" diff --git a/db/migrate/20220712153708_create_products.rb b/db/migrate/20220712153708_create_products.rb index 154e1a896..3876ca037 100644 --- a/db/migrate/20220712153708_create_products.rb +++ b/db/migrate/20220712153708_create_products.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateProducts < ActiveRecord::Migration[5.2] def change create_table :products do |t| From 19e3921dc42b6767d01b01da8e123ff51ee3051b Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 10:26:01 +0200 Subject: [PATCH 21/38] add products relation in product's category --- app/models/product.rb | 2 ++ app/models/product_category.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/models/product.rb b/app/models/product.rb index 29d0e1697..d4c26abc2 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -7,4 +7,6 @@ class Product < ApplicationRecord has_and_belongs_to_many :machines validates_numericality_of :amount, greater_than: 0, allow_nil: true + + scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_category.rb b/app/models/product_category.rb index 8feb8afda..fdc492f83 100644 --- a/app/models/product_category.rb +++ b/app/models/product_category.rb @@ -8,5 +8,7 @@ class ProductCategory < ApplicationRecord belongs_to :parent, class_name: 'ProductCategory' has_many :children, class_name: 'ProductCategory', foreign_key: :parent_id + has_many :products + acts_as_list scope: :parent, top_of_list: 0 end From e096d95dcc7c71e5cde68aeb47f5ea2be767b698 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 11:16:41 +0200 Subject: [PATCH 22/38] reset product_category_id to nil if product_category is removed --- app/services/product_category_service.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/services/product_category_service.rb b/app/services/product_category_service.rb index 4c4c2ed67..fbcc72cf8 100644 --- a/app/services/product_category_service.rb +++ b/app/services/product_category_service.rb @@ -7,7 +7,13 @@ class ProductCategoryService end def self.destroy(product_category) - ProductCategory.where(parent_id: product_category.id).destroy_all - product_category.destroy + ActiveRecord::Base.transaction do + sub_categories = ProductCategory.where(parent_id: product_category.id) + # remove product_category and sub-categories related id in product + Product.where(product_category_id: sub_categories.map(&:id).push(product_category.id)).update(product_category_id: nil) + # remove all sub-categories + sub_categories.destroy_all + product_category.destroy + end end end From 4f90cb5d8041cecf98ca5d106caa55a0c764c717 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Mon, 25 Jul 2022 16:29:01 +0200 Subject: [PATCH 23/38] update edit product comment --- app/frontend/src/javascript/components/store/edit-product.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 62ccad66c..384f4d6a0 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -16,7 +16,7 @@ interface EditProductProps { } /** - * This component show new product form + * This component show edit product form */ const EditProduct: React.FC = ({ productId, onSuccess, onError }) => { const { t } = useTranslation('admin'); From 81cc8db0f5f00b38ac98bb4bd873f31f17ffd1f8 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 25 Jul 2022 19:42:24 +0200 Subject: [PATCH 24/38] Remove react-beautiful-dnd --- .../categories/product-categories-item.tsx | 41 +++++++++++++++++++ .../categories/product-category-form.tsx | 11 ++++- config/locales/app.admin.en.yml | 1 + 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/categories/product-categories-item.tsx diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx new file mode 100644 index 000000000..1615df658 --- /dev/null +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { ProductCategory } from '../../../models/product-category'; +import { ManageProductCategory } from './manage-product-category'; +import { FabButton } from '../../base/fab-button'; +import { DotsSixVertical } from 'phosphor-react'; + +interface ProductCategoriesItemProps { + productCategories: Array, + category: ProductCategory, + onSuccess: (message: string) => void, + onError: (message: string) => void, +} + +/** + * Renders a draggable category item + */ +export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { + return ( +
+
+

{category.name}

+ [count] +
+
+
+ + +
+
+ } className='draghandle' /> +
+
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx index 87136b03e..2729d966e 100644 --- a/app/frontend/src/javascript/components/store/categories/product-category-form.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-category-form.tsx @@ -32,15 +32,22 @@ export const ProductCategoryForm: React.FC = ({ action const { register, watch, setValue, control, handleSubmit, formState } = useForm({ defaultValues: { ...productCategory } }); // filter all first level product categorie - const parents = productCategories.filter(c => !c.parent_id); + let parents = productCategories.filter(c => !c.parent_id); + if (action === 'update') { + parents = parents.filter(c => c.id !== productCategory.id); + } /** * Convert all parents to the react-select format */ const buildOptions = (): Array => { - return parents.map(t => { + const options = parents.map(t => { return { value: t.id, label: t.name }; }); + if (action === 'update') { + options.unshift({ value: null, label: t('app.admin.store.product_category_form.no_parent') }); + } + return options; }; // Create slug from category's name diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 95d249d95..103e8af33 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1914,6 +1914,7 @@ en: name: "Name of category" slug: "Name of URL" select_parent_product_category: "Choose a parent category (N1)" + no_parent: "No parent" create: error: "Unable to create the category: " success: "The new category has been created." From 1d5141d0738d2006414464ad70831779bed8b512 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 15:20:25 +0200 Subject: [PATCH 25/38] Temporary broken drag and drop --- .../categories/product-categories-item.tsx | 25 ++- .../categories/product-categories-tree.tsx | 205 +++++++++++++++--- .../store/categories/product-categories.tsx | 39 +++- .../modules/store/product-categories.scss | 16 +- package.json | 2 + yarn.lock | 36 +++ 6 files changed, 286 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 1615df658..9799eed14 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { FabButton } from '../../base/fab-button'; import { DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, + isChild?: boolean, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -14,9 +16,22 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition + } = useSortable({ id: category.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + return ( -
+

{category.name}

[count] @@ -33,7 +48,9 @@ export const ProductCategoriesItem: React.FC = ({ pr onSuccess={onSuccess} onError={onError} />
- } className='draghandle' /> +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 132a2328c..01b8797af 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,11 +1,14 @@ -import React from 'react'; +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useEffect, useState } from 'react'; +import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; -import { DotsSixVertical } from 'phosphor-react'; -import { FabButton } from '../../base/fab-button'; -import { ManageProductCategory } from './manage-product-category'; +import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { productCategories: Array, + onDnd: (list: Array) => void, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -13,30 +16,180 @@ interface ProductCategoriesTreeProps { /** * This component shows a tree list of all Product's Categories */ -export const ProductCategoriesTree: React.FC = ({ productCategories, onSuccess, onError }) => { +export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { + const [categoriesList, setCategoriesList] = useImmer(productCategories); + const [hiddenChildren, setHiddenChildren] = useState({}); + + // Initialize state from props, sorting list as a tree + useEffect(() => { + setCategoriesList(productCategories); + }, [productCategories]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + /** + * On drag start + */ + const handleDragStart = ({ active }: DragMoveEvent) => { + hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); + const activeChildren = categoriesList.filter(c => c.parent_id === active.id); + if (activeChildren.length) { + setHiddenChildren({ [active.id]: activeChildren }); + const activeIndex = categoriesList.findIndex(el => el.id === active.id); + const tmpList = [...categoriesList]; + tmpList.splice(activeIndex + 1, activeChildren.length); + setCategoriesList(tmpList); + } + }; + + /** + * On drag move + */ + const handleDragMove = ({ delta, over }: DragMoveEvent) => { + console.log(findCategory(over.id).name); + if (delta.x > 48) { + console.log('Child'); + } else { + console.log('Parent'); + } + }; + + /** + * Update categories list after an item was dropped + */ + + const handleDragEnd = ({ active, over }: DragMoveEvent) => { + let newOrder = [...categoriesList]; + + // si déplacé sur une autre catégorie… + if (active.id !== over.id) { + // liste d'ids des catégories visibles + const previousIdsOrder = over?.data.current.sortable.items; + // index dans previousIdsOrder de la catégorie déplacée + const oldIndex = active.data.current.sortable.index; + // index dans previousIdsOrder de la catégorie de réception + const newIndex = over.data.current.sortable.index; + // liste de catégories mise à jour après le drop + const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); + // id du parent de la catégorie de réception + const newParentId = categoriesList[newIndex].parent_id; + + // nouvelle liste de catégories classées par newIdsOrder + newOrder = newIdsOrder.map(sortedId => { + // catégorie courante du map retrouvée grâce à l'id + const categoryFromId = findCategory(sortedId); + // si catégorie courante = catégorie déplacée… + if (categoryFromId.id === active.id) { + // maj du parent + categoryFromId.parent_id = newParentId; + } + // retour de la catégorie courante + return categoryFromId; + }); + } + // insert siblings back + if (hiddenChildren[active.id]?.length) { + newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + } + onDnd(newOrder); + }; + + /** + * Reset state if the drag was canceled + */ + const handleDragCancel = ({ active }: DragMoveEvent) => { + setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + setCategoriesList(productCategories); + }; + + /** + * Hide children by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = findChildren(parentId); + if (children?.length) { + const tmpList = [...categoriesList]; + tmpList.splice(parentIndex + 1, children.length); + setCategoriesList(tmpList); + } + }; + + /** + * Find a category by its id + */ + const findCategory = (id) => { + return categoriesList.find(c => c.id === id); + }; + /** + * Find the children categories of a parent category by its id + */ + const findChildren = (id) => { + const displayedChildren = categoriesList.filter(c => c.parent_id === id); + if (displayedChildren.length) { + return displayedChildren; + } + return hiddenChildren[id]; + }; + /** + * Find category's status by its id + * single | parent | child + */ + const categoryStatus = (id) => { + const c = findCategory(id); + if (!c.parent_id) { + if (findChildren(id)?.length) { + return 'parent'; + } + return 'single'; + } else { + return 'child'; + } + }; + + /** + * Translate visual order into categories data positions + */ + const indexToPosition = (sortedIds: number[]) => { + const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); + const newPositions = sort.map(c => { + if (typeof c.parent_id === 'number') { + const parentIndex = sort.findIndex(el => el.id === c.parent_id); + const currentIndex = sort.findIndex(el => el.id === c.id); + return { ...c, position: (currentIndex - parentIndex - 1) }; + } + return c; + }); + return newPositions; + }; + return ( -
- {productCategories.map((category) => ( -
-
-

{category.name}

- [count] -
-
-
- + +
+ {categoriesList + .map((category) => ( + - -
- } className='draghandle' /> -
+ category={category} + onSuccess={onSuccess} + onError={onError} + isChild={typeof category.parent_id === 'number'} + /> + ))}
- ))} -
+ + ); }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 0900f56fa..e7cae432f 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -5,6 +5,7 @@ import ProductCategoryAPI from '../../../api/product-category'; import { ManageProductCategory } from './manage-product-category'; import { ProductCategoriesTree } from './product-categories-tree'; import { FabAlert } from '../../base/fab-alert'; +import { FabButton } from '../../base/fab-button'; import { HtmlTranslate } from '../../base/html-translate'; import { IApplication } from '../../../models/application'; import { Loader } from '../../base/loader'; @@ -41,28 +42,58 @@ const ProductCategories: React.FC = ({ onSuccess, onErro refreshCategories(); }; + /** + * Update state after drop + */ + const handleDnd = (data: ProductCategory[]) => { + setProductCategories(data); + }; + /** * Refresh the list of categories */ const refreshCategories = () => { ProductCategoryAPI.index().then(data => { - setProductCategories(data); + // Translate ProductCategory.position to array index + const sortedCategories = data + .filter(c => !c.parent_id) + .sort((a, b) => a.position - b.position); + const childrenCategories = data + .filter(c => typeof c.parent_id === 'number') + .sort((a, b) => b.position - a.position); + childrenCategories.forEach(c => { + const parentIndex = sortedCategories.findIndex(i => i.id === c.parent_id); + sortedCategories.splice(parentIndex + 1, 0, c); + }); + setProductCategories(sortedCategories); }).catch((error) => onError(error)); }; + /** + * Save list's new order + */ + const handleSave = () => { + // TODO: index to position -> send to API + console.log('save order:', productCategories); + }; + return (

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

- +
+ + Plop +
); diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 3aab16990..5aec87ae3 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -22,6 +22,16 @@ display: flex; justify-content: space-between; align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + .saveBtn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + } h2 { margin: 0; @include title-lg; @@ -40,8 +50,8 @@ } &-tree { - & > *:not(:last-of-type) { - margin-bottom: 1.6rem; + & > *:not(:first-child) { + margin-top: 1.6rem; } } &-item { @@ -94,4 +104,4 @@ cursor: grab; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index baadafef9..ff8113ae1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@babel/preset-typescript": "^7.16.7", "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", + "@dnd-kit/core": "^6.0.5", + "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", "@stripe/react-stripe-js": "^1.4.0", diff --git a/yarn.lock b/yarn.lock index 1f2827b84..968221b91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1488,6 +1488,37 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz#d5e0706cf8c6acd8c6032f8d54070af261bbbb2f" integrity sha512-ws57AidsDvREKrZKYffXddNkyaF14iHNHm8VQnZH6t99E8gczjNN0GpvcGny0imC80yQ0tHz1xVUKk/KFQSUyA== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.5": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.5.tgz#5670ad0dcc83cd51dbf2fa8c6a5c8af4ac0c1989" + integrity sha512-3nL+Zy5cT+1XwsWdlXIvGIFvbuocMyB4NBxTN74DeBaBqeWdH9JsnKwQv7buZQgAHmAH+eIENfS1ginkvW6bCw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" + integrity sha512-n77qAzJQtMMywu25sJzhz3gsHnDOUlEjTtnRl8A87rWIhnu32zuP+7zmFjwGgvqfXmRufqiHOSlH7JPC/tnJ8Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.0.tgz#b3e956ea63a1347c9d0e1316b037ddcc6140acda" + integrity sha512-h65/pn2IPCCIWwdlR2BMLqRkDxpTEONA+HQW3n765HBijLYGyrnTCLa2YQt8VVjjSQD6EfFlTE6aS2Q/b6nb2g== + dependencies: + tslib "^2.0.0" + "@emotion/babel-plugin@^11.7.1": version "11.9.2" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.9.2.tgz#723b6d394c89fb2ef782229d92ba95a740576e95" @@ -7452,6 +7483,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" From ab6d91fd12cd58917c4fd217157daf4cf53b1dab Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 27 Jul 2022 15:35:57 +0200 Subject: [PATCH 26/38] (quality) rename check-list to checklist and added an uncheck all button --- ...form-check-list.tsx => form-checklist.tsx} | 46 +++++++++++-------- .../components/store/product-form.tsx | 4 +- app/frontend/src/stylesheets/application.scss | 2 +- .../modules/form/form-check-list.scss | 17 ------- .../modules/form/form-checklist.scss | 28 +++++++++++ config/locales/app.shared.en.yml | 3 +- 6 files changed, 61 insertions(+), 39 deletions(-) rename app/frontend/src/javascript/components/form/{form-check-list.tsx => form-checklist.tsx} (67%) delete mode 100644 app/frontend/src/stylesheets/modules/form/form-check-list.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-checklist.scss diff --git a/app/frontend/src/javascript/components/form/form-check-list.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx similarity index 67% rename from app/frontend/src/javascript/components/form/form-check-list.tsx rename to app/frontend/src/javascript/components/form/form-checklist.tsx index 1299dcd56..63914e2e7 100644 --- a/app/frontend/src/javascript/components/form/form-check-list.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -1,4 +1,4 @@ -import React, { BaseSyntheticEvent } from 'react'; +import React from 'react'; import { Controller, Path, FieldPathValue } from 'react-hook-form'; import { FieldValues } from 'react-hook-form/dist/types/fields'; import { FieldPath } from 'react-hook-form/dist/types/path'; @@ -13,16 +13,16 @@ import { FabButton } from '../base/fab-button'; */ export type ChecklistOption = { value: TOptionValue, label: string }; -interface FormCheckListProps extends FormControlledComponent, AbstractFormItemProps { +interface FormChecklistProps extends FormControlledComponent, AbstractFormItemProps { defaultValue?: Array, options: Array>, onChange?: (values: Array) => void, } /** - * This component is a template for an check list component to use within React Hook Form + * This component is a template for a checklist component to use within React Hook Form */ -export const FormCheckList = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormCheckListProps) => { +export const FormChecklist = ({ id, control, label, tooltip, defaultValue, className, rules, disabled, error, warning, formState, onChange, options }: FormChecklistProps) => { const { t } = useTranslation('shared'); /** @@ -35,15 +35,15 @@ export const FormCheckList = , values: Array = [], cb: (value: Array) => void) => { - return (event: BaseSyntheticEvent) => { + const toggleCheckbox = (option: ChecklistOption, rhfValues: Array = [], rhfCallback: (value: Array) => void) => { + return (event: React.ChangeEvent) => { let newValues: Array = []; if (event.target.checked) { - newValues = values.concat(option.value); + newValues = rhfValues.concat(option.value); } else { - newValues = values.filter(v => v !== option.value); + newValues = rhfValues.filter(v => v !== option.value); } - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } @@ -51,26 +51,33 @@ export const FormCheckList = ) => void) => { + const selectAll = (rhfCallback: (value: Array) => void) => { return () => { const newValues: Array = options.map(o => o.value); - cb(newValues); + rhfCallback(newValues); if (typeof onChange === 'function') { onChange(newValues); } }; }; - // Compose classnames from props - const classNames = [ - `${className || ''}` - ].join(' '); + /** + * Mark all options as non-selected + */ + const unselectAll = (rhfCallback: (value: Array) => void) => { + return () => { + rhfCallback([]); + if (typeof onChange === 'function') { + onChange([]); + } + }; + }; return ( } @@ -90,7 +97,10 @@ export const FormCheckList = - {t('app.shared.form_check_list.select_all')} +
+ {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')} +
); }} /> diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e4198bbd5..cb388da7f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -8,7 +8,7 @@ import { Product } from '../../models/product'; import { FormInput } from '../form/form-input'; import { FormSwitch } from '../form/form-switch'; import { FormSelect } from '../form/form-select'; -import { FormCheckList } from '../form/form-check-list'; +import { FormChecklist } from '../form/form-checklist'; import { FormRichText } from '../form/form-rich-text'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; @@ -177,7 +177,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc - diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index b3543e447..6f01cd5cd 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -38,7 +38,7 @@ @import "modules/form/form-input"; @import "modules/form/form-rich-text"; @import "modules/form/form-switch"; -@import "modules/form/form-check-list"; +@import "modules/form/form-checklist"; @import "modules/group/change-group"; @import "modules/machines/machine-card"; @import "modules/machines/machines-filters"; diff --git a/app/frontend/src/stylesheets/modules/form/form-check-list.scss b/app/frontend/src/stylesheets/modules/form/form-check-list.scss deleted file mode 100644 index f2b255c7d..000000000 --- a/app/frontend/src/stylesheets/modules/form/form-check-list.scss +++ /dev/null @@ -1,17 +0,0 @@ -.form-check-list { - position: relative; - - .form-item-field { - display: block !important; - } - - .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; - } - - .checklist-item { - flex: 0 0 33.333333%; - } -} diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss new file mode 100644 index 000000000..13b058739 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -0,0 +1,28 @@ +.form-checklist { + position: relative; + + .form-item-field { + display: block !important; + } + + .checklist { + display: flex; + padding: 16px; + flex-wrap: wrap; + + .checklist-item { + flex: 0 0 33.333333%; + + & > input { + margin-right: 1em; + } + } + } + + .actions { + display: flex; + justify-content: space-evenly; + width: 50%; + margin: auto auto 1.2em; + } +} diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 14ff7ba51..123885afa 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -550,5 +550,6 @@ en: validate_button: "Validate the new card" form_multi_select: create_label: "Add {VALUE}" - form_check_list: + form_checklist: select_all: "Select all" + unselect_all: "Unselect all" From 4e65396e7e85dcf1ee63cb40a6e9faf3c94f7003 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 18:13:18 +0200 Subject: [PATCH 27/38] (wip) Style product's components --- .../components/form/form-checklist.tsx | 4 +- .../store/categories/product-categories.tsx | 2 +- .../components/store/edit-product.tsx | 4 +- .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 89 ++++++++++++------- .../javascript/components/store/products.tsx | 10 ++- app/frontend/src/stylesheets/application.scss | 2 + .../stylesheets/modules/base/fab-button.scss | 13 +++ .../modules/base/fab-output-copy.scss | 2 +- .../modules/form/form-checklist.scss | 30 +++---- .../stylesheets/modules/store/_utilities.scss | 14 +++ .../modules/store/product-categories.scss | 39 +++----- .../stylesheets/modules/store/products.scss | 73 +++++++++++++++ .../src/stylesheets/variables/decoration.scss | 1 + .../templates/admin/store/product_edit.html | 4 +- .../templates/admin/store/product_new.html | 4 +- 16 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/_utilities.scss create mode 100644 app/frontend/src/stylesheets/modules/store/products.scss diff --git a/app/frontend/src/javascript/components/form/form-checklist.tsx b/app/frontend/src/javascript/components/form/form-checklist.tsx index 63914e2e7..ac457a248 100644 --- a/app/frontend/src/javascript/components/form/form-checklist.tsx +++ b/app/frontend/src/javascript/components/form/form-checklist.tsx @@ -98,8 +98,8 @@ export const FormChecklist =
- {t('app.shared.form_checklist.select_all')} - {t('app.shared.form_checklist.unselect_all')} + {t('app.shared.form_checklist.select_all')} + {t('app.shared.form_checklist.unselect_all')}
); diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index e7cae432f..449a965fd 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Plop
diff --git a/app/frontend/src/javascript/components/store/edit-product.tsx b/app/frontend/src/javascript/components/store/edit-product.tsx index 384f4d6a0..27ef76e1f 100644 --- a/app/frontend/src/javascript/components/store/edit-product.tsx +++ b/app/frontend/src/javascript/components/store/edit-product.tsx @@ -39,7 +39,9 @@ const EditProduct: React.FC = ({ productId, onSuccess, onError if (product) { return ( - +
+ +
); } return null; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index e3f2ca4d2..44c336ffa 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -43,7 +43,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { }; return ( - +
+ +
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index cb388da7f..b4caa92a3 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -119,19 +119,26 @@ export const ProductForm: React.FC = ({ product, title, onSucc return ( <> -

{title}

- {t('app.admin.store.product_form.save')} +
+

{title}

+
+ {t('app.admin.store.product_form.save')} +
+
- - +
+ + +
= ({ product, title, onSucc id="is_active" formState={formState} label={t('app.admin.store.product_form.is_show_in_store')} /> + +
+
-

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

- +
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ +
{isActivePrice &&
- - +
+ + +
} + +
+

{t('app.admin.store.product_form.assigning_category')}

@@ -173,6 +190,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="product_category_id" formState={formState} label={t('app.admin.store.product_form.linking_product_to_category')} /> + +
+

{t('app.admin.store.product_form.assigning_machines')}

@@ -181,6 +201,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc control={control} id="machine_ids" formState={formState} /> + +
+

{t('app.admin.store.product_form.product_description')}

@@ -191,7 +214,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc id="description" />
- {t('app.admin.store.product_form.save')} + {t('app.admin.store.product_form.save')}
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 7f60082a3..5abf35b0f 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -58,9 +58,13 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; return ( -
-

{t('app.admin.store.products.all_products')}

- {t('app.admin.store.products.create_a_product')} +
+
+

{t('app.admin.store.products.all_products')}

+
+ {t('app.admin.store.products.create_a_product')} +
+
input { - background-color: var(--gray-soft); + background-color: var(--gray-soft-dark); border-top-right-radius: 0; border-bottom-right-radius: 0; } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 13b058739..4703c2313 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -1,28 +1,28 @@ .form-checklist { - position: relative; - .form-item-field { - display: block !important; + display: flex; + flex-direction: column; + border: none; } .checklist { - display: flex; - padding: 16px; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.6rem 3.2rem; - .checklist-item { - flex: 0 0 33.333333%; - - & > input { - margin-right: 1em; - } + .checklist-item input { + margin-right: 1em; } } .actions { + align-self: flex-end; + margin: 2.4rem 0; display: flex; - justify-content: space-evenly; - width: 50%; - margin: auto auto 1.2em; + justify-content: flex-end; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } } } diff --git a/app/frontend/src/stylesheets/modules/store/_utilities.scss b/app/frontend/src/stylesheets/modules/store/_utilities.scss new file mode 100644 index 000000000..12cff16e3 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/_utilities.scss @@ -0,0 +1,14 @@ +@mixin btn { + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0; + background: none; + border: none; + &:active { + color: currentColor; + box-shadow: none; + } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 5aec87ae3..bfc60dfbe 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -1,18 +1,3 @@ -@mixin btn { - width: 4rem; - height: 4rem; - display: inline-flex; - justify-content: center; - align-items: center; - padding: 0; - background: none; - border: none; - &:active { - color: currentColor; - box-shadow: none; - } -} - .product-categories { max-width: 1300px; margin: 0 auto; @@ -25,28 +10,28 @@ .grpBtn { display: flex; & > *:not(:first-child) { margin-left: 2.4rem; } - .saveBtn { - background-color: var(--main); + .create-button { + background-color: var(--gray-hard-darkest); + border-color: var(--gray-hard-darkest); color: var(--gray-soft-lightest); - border: none; - &:hover { opacity: 0.75; } + &:hover { + background-color: var(--gray-hard-light); + border-color: var(--gray-hard-light); + } } } h2 { margin: 0; @include title-lg; - color: var(--gray-hard-darkest); + color: var(--gray-hard-darkest) !important; } } - .create-button { - background-color: var(--gray-hard-darkest); - border-color: var(--gray-hard-darkest); + .main-action-btn { + background-color: var(--main); color: var(--gray-soft-lightest); - &:hover { - background-color: var(--gray-hard-light); - border-color: var(--gray-hard-light); - } + border: none; + &:hover { opacity: 0.75; } } &-tree { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss new file mode 100644 index 000000000..1a52e6eb8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -0,0 +1,73 @@ +.products, +.new-product, +.edit-product { + margin: 0 auto; + padding-bottom: 6rem; + + .back-btn { + margin: 2.4rem 0; + padding: 0.4rem 0.8rem; + display: inline-flex; + align-items: center; + background-color: var(--gray-soft-darkest); + border-radius: var(--border-radius-sm); + color: var(--gray-soft-lightest); + i { margin-right: 0.8rem; } + + &:hover { + background-color: var(--gray-hard-lightest); + cursor: pointer; + } + } + + header { + padding: 2.4rem 0; + display: flex; + justify-content: space-between; + align-items: center; + .grpBtn { + display: flex; + & > *:not(:first-child) { margin-left: 2.4rem; } + } + h2 { + margin: 0; + @include title-lg; + color: var(--gray-hard-darkest) !important; + } + } + + .main-action-btn { + background-color: var(--main); + color: var(--gray-soft-lightest); + border: none; + &:hover { opacity: 0.75; } + } + + .main-actions { + display: flex; + justify-content: center; + align-items: center; + & > *:not(:first-child) { + margin-left: 1.6rem; + } + } +} + +.products { + max-width: 1600px; +} + +.new-product, +.edit-product { + max-width: 1300px; + + .product-form { + .grp { + display: flex; + gap: 2.4rem 3.2rem; + .span-7 { + min-width: 70%; + } + } + } +} diff --git a/app/frontend/src/stylesheets/variables/decoration.scss b/app/frontend/src/stylesheets/variables/decoration.scss index 29a009f81..156514a71 100644 --- a/app/frontend/src/stylesheets/variables/decoration.scss +++ b/app/frontend/src/stylesheets/variables/decoration.scss @@ -1,4 +1,5 @@ :root { --border-radius: 8px; + --border-radius-sm: 4px; --shadow: 0 0 10px rgba(39, 32, 32, 0.25); } \ No newline at end of file diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index 0bdf21ca9..f098a8b92 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index eb61f3019..db9dae230 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -14,11 +14,11 @@
-
+
- + {{ 'app.admin.store.back_products_list' }} From 5e61e9c4090bc67ad7d93b879ed850824c17082f Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 28 Jul 2022 22:21:23 +0200 Subject: [PATCH 28/38] Fix save-btn color --- .../stylesheets/modules/settings/boolean-setting.scss | 10 +++++++--- .../modules/settings/user-validation-setting.scss | 10 +++++++--- .../src/stylesheets/modules/socials/fab-socials.scss | 8 ++++++-- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss index bf8d86c96..04f032429 100644 --- a/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/boolean-setting.scss @@ -10,9 +10,13 @@ vertical-align: middle; } .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-left: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss index ec368ce3d..75b1c5f3f 100644 --- a/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss +++ b/app/frontend/src/stylesheets/modules/settings/user-validation-setting.scss @@ -1,8 +1,12 @@ .user-validation-setting { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); - color: var(--secondary-text-color); margin-top: 15px; + background-color: var(--secondary); + border-color: var(--secondary); + color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } diff --git a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss index 4404ae008..44005e290 100644 --- a/app/frontend/src/stylesheets/modules/socials/fab-socials.scss +++ b/app/frontend/src/stylesheets/modules/socials/fab-socials.scss @@ -1,7 +1,11 @@ .fab-socials { .save-btn { - background-color: var(--secondary-dark); - border-color: var(--secondary-dark); + background-color: var(--secondary); + border-color: var(--secondary); color: var(--secondary-text-color); + &:hover { + background-color: var(--secondary-dark); + border-color: var(--secondary-dark); + } } } From be6ba8deffffcfd023f3916f7f17535acc0e984e Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 29 Jul 2022 10:56:15 +0200 Subject: [PATCH 29/38] Fix button color + standardise class names --- .../store/categories/manage-product-category.tsx | 4 ++-- .../store/categories/product-categories-item.tsx | 2 +- .../modules/store/product-categories.scss | 8 ++++---- .../supporting-documents-files.scss | 12 ++++++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx index 068c5294f..bf6be5028 100644 --- a/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx +++ b/app/frontend/src/javascript/components/store/categories/manage-product-category.tsx @@ -54,12 +54,12 @@ export const ManageProductCategory: React.FC = ({ pr case 'update': return (} - className="edit-button" + className="edit-btn" onClick={toggleModal} />); case 'delete': return (} - className="delete-button" + className="delete-btn" onClick={toggleModal} />); } }; diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 9799eed14..ac0bed4cb 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -36,7 +36,7 @@ export const ProductCategoriesItem: React.FC = ({ pr

{category.name}

[count]
-
+
Date: Fri, 29 Jul 2022 10:58:03 +0200 Subject: [PATCH 30/38] (wip) Style products list and form --- .../components/store/product-form.tsx | 32 ++-- .../components/store/products-list.tsx | 28 ++-- .../javascript/components/store/products.tsx | 45 +++++- .../stylesheets/modules/base/fab-button.scss | 11 ++ .../modules/form/form-checklist.scss | 2 +- .../stylesheets/modules/store/products.scss | 149 +++++++++++++++++- app/frontend/templates/admin/store/index.html | 2 +- .../templates/admin/store/product_edit.html | 2 +- .../templates/admin/store/product_new.html | 2 +- 9 files changed, 236 insertions(+), 37 deletions(-) diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index b4caa92a3..e00f38f79 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -126,7 +126,7 @@ export const ProductForm: React.FC = ({ product, title, onSucc
-
+
= ({ product, title, onSucc + label={t('app.admin.store.product_form.sku')} + className='span-3' />
- - + + label={t('app.admin.store.product_form.slug')} + className='span-7' /> + +

-
+

{t('app.admin.store.product_form.price_and_rule_of_selling_product')}

+ onChange={toggleIsActivePrice} + className='span-3' />
{isActivePrice &&
-
+
= ({ products, onEdit, on }; return ( -
+ <> {products.map((product) => ( -
- {product.name} -
- - - - - - +
+
+ +

{product.name}

+
+
+
+
+ + + + + + +
))} -
+ ); }; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 5abf35b0f..6d27af5b1 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -65,11 +65,46 @@ const Products: React.FC = ({ onSuccess, onError }) => { {t('app.admin.store.products.create_a_product')}
- +
+
+
+

Filtrer

+
+ Clear +
+
+
+
+
+
+

Result count: {products.length}

+
+
+
+

Display options:

+
+
+ +
+
+
+
+
+

feature name

+ +
+
+

long feature name

+ +
+
+ +
+
); }; diff --git a/app/frontend/src/stylesheets/modules/base/fab-button.scss b/app/frontend/src/stylesheets/modules/base/fab-button.scss index cb661590a..5bf2ece3f 100644 --- a/app/frontend/src/stylesheets/modules/base/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/base/fab-button.scss @@ -62,4 +62,15 @@ opacity: 0.75; } } + &.is-black { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + &:hover { + border-color: var(--gray-hard-darkest); + background-color: var(--gray-hard-darkest); + color: var(--gray-soft-lightest); + opacity: 0.75; + } + } } diff --git a/app/frontend/src/stylesheets/modules/form/form-checklist.scss b/app/frontend/src/stylesheets/modules/form/form-checklist.scss index 4703c2313..20721b0c5 100644 --- a/app/frontend/src/stylesheets/modules/form/form-checklist.scss +++ b/app/frontend/src/stylesheets/modules/form/form-checklist.scss @@ -7,7 +7,7 @@ .checklist { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.6rem 3.2rem; .checklist-item input { diff --git a/app/frontend/src/stylesheets/modules/store/products.scss b/app/frontend/src/stylesheets/modules/store/products.scss index 1a52e6eb8..05f4d59e6 100644 --- a/app/frontend/src/stylesheets/modules/store/products.scss +++ b/app/frontend/src/stylesheets/modules/store/products.scss @@ -34,6 +34,19 @@ @include title-lg; color: var(--gray-hard-darkest) !important; } + h3 { + margin: 0; + @include text-lg(600); + color: var(--gray-hard-darkest) !important; + } + } + + .layout { + display: flex; + align-items: flex-end; + gap: 0 3.2rem; + .span-7 { flex: 1 1 70%; } + .span-3 { flex: 1 1 30%; } } .main-action-btn { @@ -55,18 +68,146 @@ .products { max-width: 1600px; + + .layout { + align-items: flex-start; + } + + &-filters { + padding-top: 1.6rem; + border-top: 1px solid var(--gray-soft-dark); + } + + &-list { + .status { + padding: 1.6rem 2.4rem; + display: flex; + justify-content: space-between; + background-color: var(--gray-soft); + border-radius: var(--border-radius); + p { margin: 0; } + .count { + p { + display: flex; + align-items: center; + @include text-sm; + span { + margin-left: 1.6rem; + @include text-lg(600); + } + } + } + } + .features { + margin: 2.4rem 0 1.6rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1.6rem 2.4rem; + &-item { + padding-left: 1.6rem; + display: flex; + align-items: center; + background-color: var(--information-light); + border-radius: 100px; + color: var(--information-dark); + p { margin: 0; } + button { + width: 3.2rem; + height: 3.2rem; + background: none; + border: none; + } + } + } + + &-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.8rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + &:not(:first-child) { + margin-top: 1.6rem; + } + + .itemInfo { + display: flex; + justify-content: flex-end; + align-items: center; + + &-thumbnail { + width: 4.8rem; + height: 4.8rem; + margin-right: 1.6rem; + object-fit: cover; + border-radius: var(--border-radius); + background-color: var(--gray-soft); + } + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn {background: var(--gray-hard-darkest) } + .delete-btn {background: var(--error) } + } + } + } + } } .new-product, .edit-product { max-width: 1300px; + padding-right: 1.6rem; + padding-left: 1.6rem; .product-form { - .grp { + .flex { display: flex; - gap: 2.4rem 3.2rem; - .span-7 { - min-width: 70%; + flex-wrap: wrap; + align-items: flex-end; + gap: 0 3.2rem; + & > * { + flex: 1 1 320px; + } + } + + .layout { + @media (max-width: 1023px) { + .span-3, + .span-7 { + flex-basis: 50%; + } + } + @media (max-width: 767px) { + flex-wrap: wrap; + } + } + + .price-data { + .layout { + align-items: center; } } } diff --git a/app/frontend/templates/admin/store/index.html b/app/frontend/templates/admin/store/index.html index 0b7557e96..745b51c57 100644 --- a/app/frontend/templates/admin/store/index.html +++ b/app/frontend/templates/admin/store/index.html @@ -17,7 +17,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_edit.html b/app/frontend/templates/admin/store/product_edit.html index f098a8b92..6c2f86d18 100644 --- a/app/frontend/templates/admin/store/product_edit.html +++ b/app/frontend/templates/admin/store/product_edit.html @@ -27,7 +27,7 @@
-
+
diff --git a/app/frontend/templates/admin/store/product_new.html b/app/frontend/templates/admin/store/product_new.html index db9dae230..8c4fb9a05 100644 --- a/app/frontend/templates/admin/store/product_new.html +++ b/app/frontend/templates/admin/store/product_new.html @@ -27,7 +27,7 @@
-
+
From ecb7f6d640be1993a130cc4a01a785093dd11a70 Mon Sep 17 00:00:00 2001 From: vincent Date: Mon, 1 Aug 2022 16:17:21 +0200 Subject: [PATCH 31/38] (wip) drag and drop --- .../categories/product-categories-item.tsx | 67 ++-- .../categories/product-categories-tree.tsx | 294 ++++++++++++------ .../stylesheets/modules/store/dropOptions.md | 35 +++ .../modules/store/product-categories.scss | 98 ++++-- package.json | 1 + yarn.lock | 8 + 6 files changed, 356 insertions(+), 147 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/store/dropOptions.md diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index ac0bed4cb..5f1143bd4 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,14 +1,19 @@ +// TODO: Remove next eslint-disable +/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { DotsSixVertical } from 'phosphor-react'; +import { CaretDown, DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, category: ProductCategory, - isChild?: boolean, + offset: boolean, + collapsed?: boolean, + handleCollapse?: (id: number) => void, + status: 'child' | 'single' | 'parent', onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -16,41 +21,53 @@ interface ProductCategoriesItemProps { /** * Renders a draggable category item */ -export const ProductCategoriesItem: React.FC = ({ productCategories, category, isChild, onSuccess, onError }) => { +export const ProductCategoriesItem: React.FC = ({ productCategories, category, offset, collapsed, handleCollapse, status, onSuccess, onError }) => { const { attributes, listeners, setNodeRef, transform, - transition + transition, + isDragging } = useSortable({ id: category.id }); const style = { - transform: CSS.Transform.toString(transform), - transition + transition, + transform: CSS.Transform.toString(transform) }; return ( -
-
-

{category.name}

- [count] -
-
-
- - +
+ {(status === 'child' || offset) && +
+ } +
+
+ {status === 'parent' &&
+ +
} +

{category.name}

+ [count]
-
- +
+
+ + +
+
+ +
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 01b8797af..6c1bc0783 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,9 +1,11 @@ +// TODO: Remove next eslint-disable /* eslint-disable @typescript-eslint/no-unused-vars */ import React, { useEffect, useState } from 'react'; import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { restrictToWindowEdges } from '@dnd-kit/modifiers'; import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { @@ -18,13 +20,18 @@ interface ProductCategoriesTreeProps { */ export const ProductCategoriesTree: React.FC = ({ productCategories, onDnd, onSuccess, onError }) => { const [categoriesList, setCategoriesList] = useImmer(productCategories); - const [hiddenChildren, setHiddenChildren] = useState({}); + const [activeData, setActiveData] = useImmer(initActiveData); + // TODO: type extractedChildren: {[parentId]: ProductCategory[]} ??? + const [extractedChildren, setExtractedChildren] = useImmer({}); + const [collapsed, setCollapsed] = useImmer([]); + const [offset, setOffset] = useState(false); - // Initialize state from props, sorting list as a tree + // Initialize state from props useEffect(() => { setCategoriesList(productCategories); }, [productCategories]); + // Dnd Kit config const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -34,145 +41,238 @@ export const ProductCategoriesTree: React.FC = ({ pr /** * On drag start + * Collect dragged items' data + * Extract children from list */ const handleDragStart = ({ active }: DragMoveEvent) => { - hideChildren(active.id, categoriesList.findIndex(el => el.id === active.id)); - const activeChildren = categoriesList.filter(c => c.parent_id === active.id); - if (activeChildren.length) { - setHiddenChildren({ [active.id]: activeChildren }); - const activeIndex = categoriesList.findIndex(el => el.id === active.id); - const tmpList = [...categoriesList]; - tmpList.splice(activeIndex + 1, activeChildren.length); - setCategoriesList(tmpList); - } + const activeIndex = active.data.current.sortable.index; + const children = getChildren(active.id); + + setActiveData(draft => { + draft.index = activeIndex; + draft.category = getCategory(active.id); + draft.status = getStatus(active.id); + draft.children = children?.length ? children : null; + }); + + setExtractedChildren(draft => { draft[active.id] = children; }); + hideChildren(active.id, activeIndex); }; /** * On drag move */ - const handleDragMove = ({ delta, over }: DragMoveEvent) => { - console.log(findCategory(over.id).name); - if (delta.x > 48) { - console.log('Child'); - } else { - console.log('Parent'); + const handleDragMove = ({ delta, active, over }: DragMoveEvent) => { + if ((getStatus(active.id) === 'single' || getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + if (delta.x > 32) { + setOffset(true); + } else { + setOffset(false); + } } }; /** - * Update categories list after an item was dropped + * On drag End + * Insert children back in list */ - const handleDragEnd = ({ active, over }: DragMoveEvent) => { let newOrder = [...categoriesList]; + const currentIdsOrder = over?.data.current.sortable.items; + let newIndex = over.data.current.sortable.index; - // si déplacé sur une autre catégorie… - if (active.id !== over.id) { - // liste d'ids des catégories visibles - const previousIdsOrder = over?.data.current.sortable.items; - // index dans previousIdsOrder de la catégorie déplacée - const oldIndex = active.data.current.sortable.index; - // index dans previousIdsOrder de la catégorie de réception - const newIndex = over.data.current.sortable.index; - // liste de catégories mise à jour après le drop - const newIdsOrder = arrayMove(previousIdsOrder, oldIndex, newIndex); - // id du parent de la catégorie de réception - const newParentId = categoriesList[newIndex].parent_id; - - // nouvelle liste de catégories classées par newIdsOrder + // [A] Single |> [B] Single + if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') { + console.log('[A] Single |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => { - // catégorie courante du map retrouvée grâce à l'id - const categoryFromId = findCategory(sortedId); - // si catégorie courante = catégorie déplacée… - if (categoryFromId.id === active.id) { - // maj du parent - categoryFromId.parent_id = newParentId; + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; } - // retour de la catégorie courante - return categoryFromId; + return category; }); } - // insert siblings back - if (hiddenChildren[active.id]?.length) { - newOrder.splice(over.data.current.sortable.index + 1, 0, ...hiddenChildren[active.id]); - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); + + // [A] Child |> [B] Single + if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + console.log('[A] Child |> [B] Single'); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (offset && sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: Number(over.id) }; + } else if (sortedId === active.id && activeData.index < newIndex) { + category = { ...category, parent_id: null }; + } + return category; + }); } + + // [A] Single || Child |>… + if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') { + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: Number(over.id) }; + } + return category; + }); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: null }; + } + return category; + }); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + } + return category; + }); + } + } + + // [A] Parent |>… + if (getStatus(active.id) === 'parent') { + // [B] Single + if (getStatus(over.id) === 'single') { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + // [B] Parent + if (getStatus(over.id) === 'parent') { + if (activeData.index < newIndex) { + const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); + newIndex = lastOverChildIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // [B] Child + if (getStatus(over.id) === 'child') { + if (activeData.index < newIndex) { + const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); + const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); + newIndex = lastSiblingIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } else { + const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); + newIndex = parentIndex; + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); + } + } + // insert children back + newOrder = showChildren(active.id, newOrder, newIndex); + } + onDnd(newOrder); + setOffset(false); }; /** - * Reset state if the drag was canceled + * On drag cancel + * Reset states */ const handleDragCancel = ({ active }: DragMoveEvent) => { - setHiddenChildren({ ...hiddenChildren, [active.id]: null }); setCategoriesList(productCategories); + setActiveData(initActiveData); + setExtractedChildren({ ...extractedChildren, [active.id]: null }); }; /** - * Hide children by their parent's id + * Get a category by its id */ - const hideChildren = (parentId, parentIndex) => { - const children = findChildren(parentId); - if (children?.length) { - const tmpList = [...categoriesList]; - tmpList.splice(parentIndex + 1, children.length); - setCategoriesList(tmpList); - } - }; - - /** - * Find a category by its id - */ - const findCategory = (id) => { + const getCategory = (id) => { return categoriesList.find(c => c.id === id); }; + /** - * Find the children categories of a parent category by its id + * Get the children categories of a parent category by its id */ - const findChildren = (id) => { + const getChildren = (id) => { const displayedChildren = categoriesList.filter(c => c.parent_id === id); if (displayedChildren.length) { return displayedChildren; } - return hiddenChildren[id]; + return extractedChildren[id]; }; + /** - * Find category's status by its id - * single | parent | child + * Get category's status by its id + * child | single | parent */ - const categoryStatus = (id) => { - const c = findCategory(id); - if (!c.parent_id) { - if (findChildren(id)?.length) { - return 'parent'; - } - return 'single'; - } else { - return 'child'; + const getStatus = (id) => { + const c = getCategory(id); + return !c.parent_id + ? getChildren(id)?.length + ? 'parent' + : 'single' + : 'child'; + }; + + /** + * Extract children from the list by their parent's id + */ + const hideChildren = (parentId, parentIndex) => { + const children = getChildren(parentId); + if (children?.length) { + const shortenList = [...categoriesList]; + shortenList.splice(parentIndex + 1, children.length); + setCategoriesList(shortenList); } }; /** - * Translate visual order into categories data positions + * Insert children back in the list by their parent's id */ - const indexToPosition = (sortedIds: number[]) => { - const sort = sortedIds.map(sortedId => categoriesList.find(el => el.id === sortedId)); - const newPositions = sort.map(c => { - if (typeof c.parent_id === 'number') { - const parentIndex = sort.findIndex(el => el.id === c.parent_id); - const currentIndex = sort.findIndex(el => el.id === c.id); - return { ...c, position: (currentIndex - parentIndex - 1) }; - } - return c; - }); - return newPositions; + const showChildren = (parentId, currentList, insertIndex) => { + if (extractedChildren[parentId]?.length) { + currentList.splice(insertIndex + 1, 0, ...extractedChildren[parentId]); + setExtractedChildren({ ...extractedChildren, [parentId]: null }); + } + return currentList; + }; + + /** + * Toggle parent category by hidding/showing its children + */ + const handleCollapse = (id) => { + const i = collapsed.findIndex(el => el === id); + if (i === -1) { + setCollapsed([...collapsed, id]); + } else { + const copy = [...collapsed]; + copy.splice(i, 1); + setCollapsed(copy); + } }; return ( @@ -185,7 +285,10 @@ export const ProductCategoriesTree: React.FC = ({ pr category={category} onSuccess={onSuccess} onError={onError} - isChild={typeof category.parent_id === 'number'} + offset={category.id === activeData.category?.id && activeData?.offset} + collapsed={collapsed.includes(category.id) || collapsed.includes(category.parent_id)} + handleCollapse={handleCollapse} + status={getStatus(category.id)} /> ))}
@@ -193,3 +296,18 @@ export const ProductCategoriesTree: React.FC = ({ pr ); }; + +interface ActiveData { + index: number, + category: ProductCategory, + status: 'child' | 'single' | 'parent', + children: ProductCategory[], + offset: boolean +} +const initActiveData: ActiveData = { + index: null, + category: null, + status: null, + children: [], + offset: false +}; diff --git a/app/frontend/src/stylesheets/modules/store/dropOptions.md b/app/frontend/src/stylesheets/modules/store/dropOptions.md new file mode 100644 index 000000000..d8ff63b24 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/dropOptions.md @@ -0,0 +1,35 @@ + + +## [A] Single |> [B] Single + [A] = index de [B] + offset && [A] child de [B] + + + + + +## [A] Child |> [B] Single + [A] = index de [B] + offset + ? [A] child de [B] + : [A] Single + + + + + + + +## [A] Single |> [A] + offset && [A] child du précédant parent \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/store/product-categories.scss b/app/frontend/src/stylesheets/modules/store/product-categories.scss index 7e2f2b0dc..1f3585856 100644 --- a/app/frontend/src/stylesheets/modules/store/product-categories.scss +++ b/app/frontend/src/stylesheets/modules/store/product-categories.scss @@ -41,50 +41,80 @@ } &-item { display: flex; - justify-content: space-between; - align-items: center; - padding: 0.6rem 1.6rem; - border: 1px solid var(--gray-soft-dark); - border-radius: var(--border-radius); + pointer-events: all; - .itemInfo { - display: flex; - justify-content: flex-end; - align-items: center; - &-name { - margin: 0; - @include text-base; - font-weight: 600; - color: var(--gray-hard-darkest); - } - &-count { - margin-left: 2.4rem; - @include text-sm; - font-weight: 500; - color: var(--information); - } + &.is-collapsed { + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; + pointer-events: none; + } + .offset { + width: 4.8rem; } - .actions { + .wrap { + width: 100%; display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; - .manage { - overflow: hidden; + padding: 0.6rem 1.6rem; + border: 1px solid var(--gray-soft-dark); + border-radius: var(--border-radius); + background-color: var(--gray-soft-lightest); + .itemInfo { display: flex; - border-radius: var(--border-radius-sm); - button { - @include btn; - border-radius: 0; - color: var(--gray-soft-lightest); - &:hover { opacity: 0.75; } + align-items: center; + &-name { + margin: 0; + @include text-base; + font-weight: 600; + color: var(--gray-hard-darkest); + } + &-count { + margin-left: 2.4rem; + @include text-sm; + font-weight: 500; + color: var(--information); + } + } + + .actions { + display: flex; + justify-content: flex-end; + align-items: center; + .manage { + overflow: hidden; + display: flex; + border-radius: var(--border-radius-sm); + button { + @include btn; + border-radius: 0; + color: var(--gray-soft-lightest); + &:hover { opacity: 0.75; } + } + .edit-btn { background: var(--gray-hard-darkest); } + .delete-btn { background: var(--error); } } - .edit-btn {background: var(--gray-hard-darkest) } - .delete-btn {background: var(--error) } } } - .draghandle { + .collapse-handle { + width: 4rem; + margin: 0 0 0 -1rem; + button { + @include btn; + background: none; + border-radius: 0; + transition: transform 250ms ease-in-out; + &.rotate { + transform: rotateZ(-180deg); + } + } + } + .drag-handle button { @include btn; cursor: grab; } diff --git a/package.json b/package.json index ff8113ae1..852452ee8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@babel/runtime": "^7.17.2", "@claviska/jquery-minicolors": "^2.3.5", "@dnd-kit/core": "^6.0.5", + "@dnd-kit/modifiers": "^6.0.0", "@dnd-kit/sortable": "^7.0.1", "@fortawesome/fontawesome-free": "5.14.0", "@lyracom/embedded-form-glue": "^0.3.3", diff --git a/yarn.lock b/yarn.lock index 968221b91..b82199d18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1504,6 +1504,14 @@ "@dnd-kit/utilities" "^3.2.0" tslib "^2.0.0" +"@dnd-kit/modifiers@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.0.tgz#61d8834132f791a68e9e93be5426becbcd45c078" + integrity sha512-V3+JSo6/BTcgPRHiNUTSKgqVv/doKXg+T4Z0QvKiiXp+uIyJTUtPkQOBRQApUWi3ApBhnoWljyt/3xxY4fTd0Q== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + "@dnd-kit/sortable@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.1.tgz#99c6012bbab4d8bb726c0eef7b921a338c404fdb" From ea1171ba0f825fe5d3fb1d50154074ac2b8e1d43 Mon Sep 17 00:00:00 2001 From: vincent Date: Wed, 3 Aug 2022 09:59:52 +0200 Subject: [PATCH 32/38] Remove test text --- .../components/store/categories/product-categories.tsx | 2 +- app/frontend/templates/admin/calendar/icalendar.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index 449a965fd..25a7dc617 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -85,7 +85,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Plop + Save
diff --git a/app/frontend/templates/admin/calendar/icalendar.html b/app/frontend/templates/admin/calendar/icalendar.html index ab91191d3..edd60ecfe 100644 --- a/app/frontend/templates/admin/calendar/icalendar.html +++ b/app/frontend/templates/admin/calendar/icalendar.html @@ -38,7 +38,7 @@ - {{calendar.name}} plop + {{calendar.name}} {{calendar.url}} {{ calendar.text_hidden ? '' : 'app.admin.icalendar.example' }} From 0773e5bc821518d9d05a587ccc9e900419992784 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Tue, 2 Aug 2022 19:47:56 +0200 Subject: [PATCH 33/38] product files and images upload --- app/controllers/api/products_controller.rb | 23 +++- app/frontend/src/javascript/api/product.ts | 63 ++++++++- .../components/form/form-file-upload.tsx | 129 ++++++++++++++++++ .../components/form/form-image-upload.tsx | 119 ++++++++++++++++ .../components/store/new-product.tsx | 4 +- .../components/store/product-form.tsx | 85 +++++++++++- app/frontend/src/javascript/models/product.ts | 16 +++ app/frontend/src/stylesheets/application.scss | 3 + .../modules/form/form-file-upload.scss | 106 ++++++++++++++ .../modules/form/form-image-upload.scss | 48 +++++++ .../modules/store/product-form.scss | 4 + app/models/product.rb | 6 + app/models/product_file.rb | 6 + app/models/product_image.rb | 6 + app/uploaders/product_file_uploader.rb | 66 +++++++++ app/uploaders/product_image_uploader.rb | 76 +++++++++++ app/views/api/products/_product.json.jbuilder | 10 ++ config/locales/app.admin.en.yml | 6 + config/locales/app.admin.fr.yml | 6 + config/locales/app.shared.en.yml | 6 + config/locales/app.shared.fr.yml | 6 + 21 files changed, 785 insertions(+), 9 deletions(-) create mode 100644 app/frontend/src/javascript/components/form/form-file-upload.tsx create mode 100644 app/frontend/src/javascript/components/form/form-image-upload.tsx create mode 100644 app/frontend/src/stylesheets/modules/form/form-file-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/form/form-image-upload.scss create mode 100644 app/frontend/src/stylesheets/modules/store/product-form.scss create mode 100644 app/models/product_file.rb create mode 100644 app/models/product_image.rb create mode 100644 app/uploaders/product_file_uploader.rb create mode 100644 app/uploaders/product_image_uploader.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index e411ce090..b21e65be1 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -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 diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index edb434c95..c89a32642 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -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 { - const res: AxiosResponse = 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 = await apiClient.post('/api/products', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } static async update (product: Product): Promise { - const res: AxiosResponse = 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 = await apiClient.patch(`/api/products/${product.id}`, data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); return res?.data; } diff --git a/app/frontend/src/javascript/components/form/form-file-upload.tsx b/app/frontend/src/javascript/components/form/form-file-upload.tsx new file mode 100644 index 000000000..b545ebbbb --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-file-upload.tsx @@ -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 extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + 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 = ({ id, register, defaultFile, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue }: FormFileUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(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) { + const f = event.target?.files[0]; + if (f) { + setFile({ + attachment_name: f.name + }); + setValue( + `${id}[_destroy]` as Path, + false as UnpackNestedValue>> + ); + 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, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ {hasFile() && ( +
+ + + {file.attachment_name} + +
+ )} + {file?.id && file?.attachment_url && ( + + + + )} +
+ + {!hasFile() && ( + {t('app.shared.form_file_upload.browse')} + )} + {hasFile() && ( + {t('app.shared.form_file_upload.edit')} + )} + + + {hasFile() && ( + + + + )} +
+ ); +}; diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx new file mode 100644 index 000000000..76bde2632 --- /dev/null +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -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 extends FormComponent, AbstractFormItemProps { + setValue: UseFormSetValue, + 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 = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { + const { t } = useTranslation('shared'); + + const [file, setFile] = useState(defaultImage); + const [image, setImage] = useState(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) { + 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, + false as UnpackNestedValue>> + ); + 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, + true as UnpackNestedValue>> + ); + } + setValue( + `${id}[attachment_files]` as Path, + null as UnpackNestedValue>> + ); + setFile(null); + setImage(null); + if (typeof onFileRemove === 'function') { + onFileRemove(); + } + } + + // Compose classnames from props + const classNames = [ + `${className || ''}` + ].join(' '); + + return ( +
+
+ +
+
+ + {!hasImage() && {t('app.shared.form_image_upload.browse')}} + {hasImage() && {t('app.shared.form_image_upload.edit')}} + + + {hasImage() && } className="delete-image" />} +
+
+ ); +}; diff --git a/app/frontend/src/javascript/components/store/new-product.tsx b/app/frontend/src/javascript/components/store/new-product.tsx index 44c336ffa..5747e50ed 100644 --- a/app/frontend/src/javascript/components/store/new-product.tsx +++ b/app/frontend/src/javascript/components/store/new-product.tsx @@ -31,7 +31,9 @@ const NewProduct: React.FC = ({ onSuccess, onError }) => { external: 0 }, low_stock_alert: false, - machine_ids: [] + machine_ids: [], + product_files_attributes: [], + product_images_attributes: [] }; /** diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e00f38f79..e2139568f 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -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 = ({ product, title, onSucc const { t } = useTranslation('admin'); const { handleSubmit, register, control, formState, setValue, reset } = useForm({ defaultValues: { ...product } }); + const output = useWatch({ control }); const [isActivePrice, setIsActivePrice] = useState(product.id && _.isFinite(product.amount) && product.amount > 0); const [productCategories, setProductCategories] = useState([]); const [machines, setMachines] = useState([]); @@ -117,6 +120,46 @@ export const ProductForm: React.FC = ({ 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 ( <>
@@ -187,6 +230,28 @@ export const ProductForm: React.FC = ({ product, title, onSucc
+
+

{t('app.admin.store.product_form.product_images')}

+ + + +
+ {output.product_images_attributes.map((image, i) => ( + + ))} +
+ {t('app.admin.store.product_form.add_product_image')} +

{t('app.admin.store.product_form.assigning_category')}

@@ -218,6 +283,24 @@ export const ProductForm: React.FC = ({ product, title, onSucc paragraphTools={true} limit={1000} id="description" /> +
+

{t('app.admin.store.product_form.product_files')}

+ + + + {output.product_files_attributes.map((file, i) => ( + + ))} + {t('app.admin.store.product_form.add_product_file')} +
{t('app.admin.store.product_form.save')} diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 9038dbd5c..471e00248 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -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 + }> } diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index a461b6ae3..704204551 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -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"; diff --git a/app/frontend/src/stylesheets/modules/form/form-file-upload.scss b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss new file mode 100644 index 000000000..284bc29c4 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-file-upload.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss new file mode 100644 index 000000000..fa35d9a26 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/store/product-form.scss b/app/frontend/src/stylesheets/modules/store/product-form.scss new file mode 100644 index 000000000..7c45aa8a7 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/store/product-form.scss @@ -0,0 +1,4 @@ +.product-images { + display: flex; + flex-wrap: wrap; +} diff --git a/app/models/product.rb b/app/models/product.rb index d4c26abc2..7bc4087a7 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -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) } diff --git a/app/models/product_file.rb b/app/models/product_file.rb new file mode 100644 index 000000000..fd23a9b3f --- /dev/null +++ b/app/models/product_file.rb @@ -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 diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 000000000..9a5da4a85 --- /dev/null +++ b/app/models/product_image.rb @@ -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 diff --git a/app/uploaders/product_file_uploader.rb b/app/uploaders/product_file_uploader.rb new file mode 100644 index 000000000..1cee5d75c --- /dev/null +++ b/app/uploaders/product_file_uploader.rb @@ -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 diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb new file mode 100644 index 000000000..a7830ab81 --- /dev/null +++ b/app/uploaders/product_image_uploader.rb @@ -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 diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 624f8e45d..9b9a99336 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -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 diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 103e8af33..ee7e70f66 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1954,4 +1954,10 @@ en: assigning_machines_info: "Information
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.
The machines selected below will be linked to the product." product_description: "Product description" product_description_info: "Information
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: "Information
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: "Advice
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" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index fabc04045..2e9847512 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1953,4 +1953,10 @@ fr: assigning_machines_info: "Information
Vous pouvez lier une ou plusieurs machines de votre fablab à votre produit, Ce produit sera alors assujetti aux filtres sur la vue catalogue.
Les machines sélectionnées ci-dessous seront liées au produit." product_description: "Description du produit" product_description_info: "Information
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: "Information
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: "Conseils
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" diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 123885afa..3fc1e941e 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -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" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 0827df858..b72887968 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -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" From 9561e61f5a1b6c1d2324fd0b43ad771419e676f6 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 10:30:30 +0200 Subject: [PATCH 34/38] update locale fr --- config/locales/app.admin.fr.yml | 12 ++++++------ config/locales/app.shared.fr.yml | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index 2e9847512..96c21c09c 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -1904,9 +1904,9 @@ fr: title: "Les catégories" info: "Information:
Retrouvez ci-dessous toutes les catégories créés. Les catégories se rangent sur deux niveaux maximum, vous pouvez les agencer avec un glisser-déposer. L'ordre d'affichage des catégories sera identique sur la vue publique et la liste ci-dessous. Attention, Vous pouvez supprimer une catégorie ou une sous-catégorie même si elles sont associées à des produits. Ces derniers se retrouveront sans catégories. Si vous supprimez une catégorie contenant des sous-catégories, ces dernières seront elles aussi supprimées. Veillez au bon agencement de vos catégories et sauvegarder votre choix." manage_product_category: - create: "Create a product category" - update: "Update the product category" - delete: "Delete the product category" + create: "Créer une catégorie" + update: "Modifier la catégorie" + delete: "Supprimer la catégorie" product_category_modal: new_product_category: "Créer une catégorie" edit_product_category: "Modifier la catégorie" @@ -1921,12 +1921,12 @@ fr: error: "Impossible de modifier la catégorie : " success: "La nouvelle catégorie a bien été mise à jour." delete: - confirm: "Do you really want to delete this product category?" + confirm: "Voulez-vous vraiment supprimer cette catégorie de produits ?" error: "Impossible de supprimer the catégorie : " success: "La catégorie a bien été supprimée" save: "Enregistrer" - required: "This field is required" - slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + required: "Le champ est requise" + slug_pattern: "Uniquement des groupes de caractères alphanumériques minuscules séparés par un trait d'union." products: all_products: "Tous les produits" create_a_product: "Créer un produit" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index b72887968..139728a02 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -550,8 +550,9 @@ fr: validate_button: "Valider la nouvelle carte" form_multi_select: create_label: "Ajouter {VALUE}" - form_check_list: + form_checklist: select_all: "Tout sélectionner" + unselect_all: "Tout désélectionner" form_file_upload: browse: "Parcourir" edit: "Modifier" From 350275d31be27e1dadd302ed033eba7fa885ad68 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Wed, 3 Aug 2022 20:16:21 +0200 Subject: [PATCH 35/38] add is_main to product image --- app/controllers/api/products_controller.rb | 2 +- app/frontend/src/javascript/api/product.ts | 2 + .../components/form/form-image-upload.tsx | 36 +++++++++-- .../components/store/product-form.tsx | 64 +++++++++++++++++-- app/frontend/src/javascript/models/product.ts | 7 +- app/views/api/products/_product.json.jbuilder | 4 +- config/locales/app.shared.en.yml | 1 + config/locales/app.shared.fr.yml | 1 + .../20220803091913_add_is_main_to_assets.rb | 5 ++ db/schema.rb | 3 +- 10 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20220803091913_add_is_main_to_assets.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index b21e65be1..65a13eeb0 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -65,6 +65,6 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment _destroy]) + product_images_attributes: %i[id attachment is_main _destroy]) end end diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index c89a32642..a3c0a4e92 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -32,6 +32,7 @@ export default class ProductAPI { 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]); + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); } }); const res: AxiosResponse = await apiClient.post('/api/products', data, { @@ -73,6 +74,7 @@ export default class ProductAPI { if (image?._destroy) { data.set(`product[product_images_attributes][${i}][_destroy]`, image._destroy.toString()); } + data.set(`product[product_images_attributes][${i}][is_main]`, (!!image.is_main).toString()); }); const res: AxiosResponse = await apiClient.patch(`/api/products/${product.id}`, data, { headers: { diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 76bde2632..9214054b6 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Path } from 'react-hook-form'; import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form'; @@ -13,7 +13,8 @@ import noAvatar from '../../../../images/no_avatar.png'; export interface ImageType { id?: number, attachment_name?: string, - attachment_url?: string + attachment_url?: string, + is_main?: boolean } interface FormImageUploadProps extends FormComponent, AbstractFormItemProps { @@ -21,19 +22,25 @@ interface FormImageUploadProps extends FormComponent defaultImage?: ImageType, accept?: string, size?: 'small' | 'large' + mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, + onFileIsMain?: () => void, } /** * This component allows to upload image, in forms managed by react-hook-form. */ -export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size }: FormImageUploadProps) => { +export const FormImageUpload = ({ id, register, defaultImage, className, rules, disabled, error, warning, formState, onFileChange, onFileRemove, accept, setValue, size, onFileIsMain, mainOption = false }: FormImageUploadProps) => { const { t } = useTranslation('shared'); const [file, setFile] = useState(defaultImage); const [image, setImage] = useState(defaultImage.attachment_url); + useEffect(() => { + setFile(defaultImage); + }, [defaultImage]); + /** * Check if image is selected */ @@ -53,8 +60,13 @@ export const FormImageUpload = ({ id, register }; reader.readAsDataURL(f); setFile({ + ...file, attachment_name: f.name }); + setValue( + `${id}[attachment_name]` as Path, + f.name as UnpackNestedValue>> + ); setValue( `${id}[_destroy]` as Path, false as UnpackNestedValue>> @@ -80,12 +92,22 @@ export const FormImageUpload = ({ id, register null as UnpackNestedValue>> ); setFile(null); - setImage(null); if (typeof onFileRemove === 'function') { onFileRemove(); } } + /** + * Callback triggered when the user set the image is main + */ + function setMainImage () { + setValue( + `${id}[is_main]` as Path, + true as UnpackNestedValue>> + ); + onFileIsMain(); + } + // Compose classnames from props const classNames = [ `${className || ''}` @@ -114,6 +136,12 @@ export const FormImageUpload = ({ id, register {hasImage() && } className="delete-image" />}
+ {mainOption && +
+ + +
+ }
); }; diff --git a/app/frontend/src/javascript/components/store/product-form.tsx b/app/frontend/src/javascript/components/store/product-form.tsx index e2139568f..dc6fe7c70 100644 --- a/app/frontend/src/javascript/components/store/product-form.tsx +++ b/app/frontend/src/javascript/components/store/product-form.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import { useForm, useWatch, Path } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import slugify from 'slugify'; import _ from 'lodash'; @@ -11,7 +11,7 @@ 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 { FormImageUpload, ImageType } from '../form/form-image-upload'; import { FabButton } from '../base/fab-button'; import { FabAlert } from '../base/fab-alert'; import ProductCategoryAPI from '../../api/product-category'; @@ -144,7 +144,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc * Add new product image */ const addProductImage = () => { - setValue('product_images_attributes', output.product_images_attributes.concat({})); + setValue('product_images_attributes', output.product_images_attributes.concat({ + is_main: output.product_images_attributes.length === 0 + })); }; /** @@ -155,7 +157,59 @@ export const ProductForm: React.FC = ({ product, title, onSucc 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); + if (productImage.is_main) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (k === 0) { + return { + ...image, + is_main: true + }; + } + return image; + })); + } else { + setValue('product_images_attributes', output.product_images_attributes); + } + } else { + if (productImage.is_main) { + let mainImage = false; + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k && !mainImage) { + mainImage = true; + return { + ...image, + _destroy: i === k, + is_main: true + }; + } + return { + ...image, + _destroy: i === k + }; + })); + } + } + }; + }; + + /** + * Remove main image in others product images + */ + const handleSetMainImage = (i: number) => { + return () => { + if (output.product_images_attributes.length > 1) { + setValue('product_images_attributes', output.product_images_attributes.map((image, k) => { + if (i !== k) { + return { + ...image, + is_main: false + }; + } + return { + ...image, + is_main: true + }; + })); } }; }; @@ -246,7 +300,9 @@ export const ProductForm: React.FC = ({ product, title, onSucc setValue={setValue} formState={formState} className={image._destroy ? 'hidden' : ''} + mainOption={true} onFileRemove={handleRemoveProductImage(i)} + onFileIsMain={handleSetMainImage(i)} /> ))}
diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 471e00248..57aa51809 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -27,7 +27,7 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string + attachment_url?: string, _destroy?: boolean }>, product_images_attributes: Array<{ @@ -35,7 +35,8 @@ export interface Product { attachment?: File, attachment_files?: FileList, attachment_name?: string, - attachment_url?: string - _destroy?: boolean + attachment_url?: string, + _destroy?: boolean, + is_main?: boolean }> } diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 9b9a99336..37d9fca9b 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true -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.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 @@ -11,4 +12,5 @@ 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 + json.is_main f.is_main end diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 3fc1e941e..f5c3abaca 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -559,3 +559,4 @@ en: form_image_upload: browse: "Browse" edit: "Edit" + main_image: "Main image" diff --git a/config/locales/app.shared.fr.yml b/config/locales/app.shared.fr.yml index 139728a02..6f2cfa3d8 100644 --- a/config/locales/app.shared.fr.yml +++ b/config/locales/app.shared.fr.yml @@ -559,3 +559,4 @@ fr: form_image_upload: browse: "Parcourir" edit: "Modifier" + main_image: "Visuel principal" diff --git a/db/migrate/20220803091913_add_is_main_to_assets.rb b/db/migrate/20220803091913_add_is_main_to_assets.rb new file mode 100644 index 000000000..3201a80a5 --- /dev/null +++ b/db/migrate/20220803091913_add_is_main_to_assets.rb @@ -0,0 +1,5 @@ +class AddIsMainToAssets < ActiveRecord::Migration[5.2] + def change + add_column :assets, :is_main, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 1222e9bcf..864a474d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_20_135828) do +ActiveRecord::Schema.define(version: 2022_08_03_091913) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -70,6 +70,7 @@ ActiveRecord::Schema.define(version: 2022_07_20_135828) do t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.boolean "is_main" end create_table "auth_provider_mappings", id: :serial, force: :cascade do |t| From 851294e8d9cc5494afab954f0f223c6adcb32e1c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 09:41:53 +0200 Subject: [PATCH 36/38] add size medium to product image --- .../components/form/form-image-upload.tsx | 2 +- .../modules/form/form-image-upload.scss | 17 +++++++++++++---- app/uploaders/product_image_uploader.rb | 4 ++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/javascript/components/form/form-image-upload.tsx b/app/frontend/src/javascript/components/form/form-image-upload.tsx index 9214054b6..5ee2ead72 100644 --- a/app/frontend/src/javascript/components/form/form-image-upload.tsx +++ b/app/frontend/src/javascript/components/form/form-image-upload.tsx @@ -21,7 +21,7 @@ interface FormImageUploadProps extends FormComponent setValue: UseFormSetValue, defaultImage?: ImageType, accept?: string, - size?: 'small' | 'large' + size?: 'small' | 'medium' | 'large' mainOption?: boolean, onFileChange?: (value: ImageType) => void, onFileRemove?: () => void, diff --git a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss index fa35d9a26..9dcc481e6 100644 --- a/app/frontend/src/stylesheets/modules/form/form-image-upload.scss +++ b/app/frontend/src/stylesheets/modules/form/form-image-upload.scss @@ -7,13 +7,18 @@ display: inline-block; &--small img { - width: 50px; - height: 50px; + width: 80px; + height: 80px; + } + + &--medium img { + width: 200px; + height: 200px; } &--large img { - width: 180px; - height: 180px; + width: 400px; + height: 400px; } } @@ -42,6 +47,10 @@ margin: 80px 40px; } + &--medium { + margin: 80px 40px; + } + &--small { text-align: center; } diff --git a/app/uploaders/product_image_uploader.rb b/app/uploaders/product_image_uploader.rb index a7830ab81..8b591a84b 100644 --- a/app/uploaders/product_image_uploader.rb +++ b/app/uploaders/product_image_uploader.rb @@ -49,6 +49,10 @@ class ProductImageUploader < CarrierWave::Uploader::Base process resize_to_fit: [700, 400] end + version :small do + process resize_to_fit: [400, 250] + end + # Add a white list of extensions which are allowed to be uploaded. # For images you might use something like this: def extension_whitelist From ec62931a78e0f463b60927df258b2aa8ea547970 Mon Sep 17 00:00:00 2001 From: Du Peng Date: Thu, 4 Aug 2022 14:02:19 +0200 Subject: [PATCH 37/38] fix bug: product amount cannot update --- app/controllers/api/products_controller.rb | 16 ++-------------- app/services/product_service.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index 65a13eeb0..ca15bfafb 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -15,13 +15,7 @@ class API::ProductsController < API::ApiController def create authorize Product @product = Product.new(product_params) - if @product.amount.present? - if @product.amount.zero? - @product.amount = nil - else - @product.amount *= 100 - end - end + @product.amount = ProductService.amount_multiplied_by_hundred(@product.amount) if @product.save render status: :created else @@ -33,13 +27,7 @@ class API::ProductsController < API::ApiController authorize @product product_parameters = product_params - if product_parameters[:amount].present? - if product_parameters[:amount].zero? - product_parameters[:amount] = nil - else - product_parameters[:amount] *= 100 - end - end + product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount]) if @product.update(product_parameters) render status: :ok else diff --git a/app/services/product_service.rb b/app/services/product_service.rb index d31f61ae8..8c712bf35 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -5,4 +5,16 @@ class ProductService def self.list Product.all end + + # amount params multiplied by hundred + def self.amount_multiplied_by_hundred(amount) + if amount.present? + v = amount.to_f + + return nil if v.zero? + + return v * 100 + end + nil + end end From f62244fcdbddbf161a40cb12cfe828750cd6007c Mon Sep 17 00:00:00 2001 From: Du Peng Date: Fri, 5 Aug 2022 15:25:51 +0200 Subject: [PATCH 38/38] add product stock mouvements --- app/controllers/api/products_controller.rb | 3 ++- app/frontend/src/javascript/models/product.ts | 13 ++++++++++++- app/models/product.rb | 5 ++++- app/models/product_stock_movement.rb | 18 ++++++++++++++++++ app/views/api/products/_product.json.jbuilder | 8 ++++++++ ...805083431_create_product_stock_movements.rb | 16 ++++++++++++++++ db/schema.rb | 15 ++++++++++++++- 7 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/models/product_stock_movement.rb create mode 100644 db/migrate/20220805083431_create_product_stock_movements.rb diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index ca15bfafb..2c5835f66 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -53,6 +53,7 @@ class API::ProductsController < API::ApiController :low_stock_alert, :low_stock_threshold, machine_ids: [], product_files_attributes: %i[id attachment _destroy], - product_images_attributes: %i[id attachment is_main _destroy]) + product_images_attributes: %i[id attachment is_main _destroy], + product_stock_movements_attributes: %i[id quantity reason stock_type _destroy]) end end diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 57aa51809..fc951c1e5 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -1,3 +1,5 @@ +import { TDateISO } from '../typings/date-iso'; + export enum StockType { internal = 'internal', external = 'external' @@ -38,5 +40,14 @@ export interface Product { attachment_url?: string, _destroy?: boolean, is_main?: boolean - }> + }>, + product_stock_movements_attributes: Array<{ + id?: number, + quantity?: number, + reason?: string, + stock_type?: string, + remaining_stock?: number, + date?: TDateISO, + _destroy?: boolean + }>, } diff --git a/app/models/product.rb b/app/models/product.rb index 7bc4087a7..946a3c454 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -12,7 +12,10 @@ class Product < ApplicationRecord 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 + has_many :product_stock_movements, dependent: :destroy + accepts_nested_attributes_for :product_stock_movements, allow_destroy: true, reject_if: :all_blank + + validates :amount, numericality: { greater_than: 0, allow_nil: true } scope :active, -> { where(is_active: true) } end diff --git a/app/models/product_stock_movement.rb b/app/models/product_stock_movement.rb new file mode 100644 index 000000000..6d5623f2f --- /dev/null +++ b/app/models/product_stock_movement.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# ProductStockMovement is a model for record the movements of product's stock +class ProductStockMovement < ApplicationRecord + belongs_to :product + + ALL_STOCK_TYPES = %w[internal external].freeze + enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h + + ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze + enum reason: ALL_REASONS.zip(ALL_REASONS).to_h + + validates :stock_type, presence: true + validates :stock_type, inclusion: { in: ALL_STOCK_TYPES } + + validates :reason, presence: true + validates :reason, inclusion: { in: ALL_REASONS } +end diff --git a/app/views/api/products/_product.json.jbuilder b/app/views/api/products/_product.json.jbuilder index 37d9fca9b..a6fe177cf 100644 --- a/app/views/api/products/_product.json.jbuilder +++ b/app/views/api/products/_product.json.jbuilder @@ -14,3 +14,11 @@ json.product_images_attributes product.product_images do |f| json.attachment_url f.attachment_url json.is_main f.is_main end +json.product_stock_movements_attributes product.product_stock_movements do |s| + json.id s.id + json.quantity s.quantity + json.reason s.reason + json.stock_type s.stock_type + json.remaining_stock s.remaining_stock + json.date s.date +end diff --git a/db/migrate/20220805083431_create_product_stock_movements.rb b/db/migrate/20220805083431_create_product_stock_movements.rb new file mode 100644 index 000000000..d7c7af564 --- /dev/null +++ b/db/migrate/20220805083431_create_product_stock_movements.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateProductStockMovements < ActiveRecord::Migration[5.2] + def change + create_table :product_stock_movements do |t| + t.belongs_to :product, foreign_key: true + t.integer :quantity + t.string :reason + t.string :stock_type + t.integer :remaining_stock + t.datetime :date + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 864a474d7..ca31162a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_03_091913) do +ActiveRecord::Schema.define(version: 2022_08_05_083431) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -597,6 +597,18 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do t.index ["parent_id"], name: "index_product_categories_on_parent_id" end + create_table "product_stock_movements", force: :cascade do |t| + t.bigint "product_id" + t.integer "quantity" + t.string "reason" + t.string "stock_type" + t.integer "remaining_stock" + t.datetime "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["product_id"], name: "index_product_stock_movements_on_product_id" + end + create_table "products", force: :cascade do |t| t.string "name" t.string "slug" @@ -1135,6 +1147,7 @@ ActiveRecord::Schema.define(version: 2022_08_03_091913) do add_foreign_key "prepaid_packs", "groups" add_foreign_key "prices", "groups" add_foreign_key "prices", "plans" + add_foreign_key "product_stock_movements", "products" add_foreign_key "products", "product_categories" add_foreign_key "project_steps", "projects" add_foreign_key "project_users", "projects"