1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

Merge branch 'project-categories' into projects-improvements

This commit is contained in:
Nicolas Florentin 2023-07-03 14:32:35 +02:00
commit a3a5527141
43 changed files with 660 additions and 40 deletions

View File

@ -82,7 +82,7 @@ GEM
rails (>= 4.1)
ast (2.4.2)
attr_required (1.0.1)
awesome_print (1.8.0)
awesome_print (1.9.2)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
# API Controller for resources of type ProjectCategory
class API::ProjectCategoriesController < ApplicationController
before_action :set_project_category, only: %i[update destroy]
before_action :authenticate_user!, only: %i[create update destroy]
def index
@project_categories = ProjectCategory.all
end
def create
authorize ProjectCategory
@project_category = ProjectCategory.new(project_category_params)
if @project_category.save
render json: @project_category, status: :created
else
render json: @project_category.errors, status: :unprocessable_entity
end
end
def update
authorize ProjectCategory
if @project_category.update(project_category_params)
render json: @project_category, status: :ok
else
render json: @project_category.errors, status: :unprocessable_entity
end
end
def destroy
authorize ProjectCategory
@project_category.destroy
head :no_content
end
private
def set_project_category
@project_category = ProjectCategory.find(params[:id])
end
def project_category_params
params.require(:project_category).permit(:name)
end
end

View File

@ -69,7 +69,7 @@ class API::ProjectsController < API::APIController
def project_params
params.require(:project).permit(:name, :description, :tags, :machine_ids, :component_ids, :theme_ids, :licence_id, :status_id, :state,
user_ids: [], machine_ids: [], component_ids: [], theme_ids: [],
user_ids: [], machine_ids: [], component_ids: [], theme_ids: [], project_category_ids: [],
project_image_attributes: [:attachment],
project_caos_attributes: %i[id attachment _destroy],
project_steps_attributes: [

View File

@ -0,0 +1,25 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProjectCategory } from '../models/project-category';
export default class ProjectCategoryAPI {
static async index (): Promise<Array<ProjectCategory>> {
const res: AxiosResponse<Array<ProjectCategory>> = await apiClient.get('/api/project_categories');
return res?.data;
}
static async create (newProjectCategory: ProjectCategory): Promise<ProjectCategory> {
const res: AxiosResponse<ProjectCategory> = await apiClient.post('/api/project_categories', { project_category: newProjectCategory });
return res?.data;
}
static async update (updatedProjectCategory: ProjectCategory): Promise<ProjectCategory> {
const res: AxiosResponse<ProjectCategory> = await apiClient.patch(`/api/project_categories/${updatedProjectCategory.id}`, { project_category: updatedProjectCategory });
return res?.data;
}
static async destroy (projectCategoryId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/project_categories/${projectCategoryId}`);
return res?.data;
}
}

View File

@ -12,8 +12,8 @@
*/
'use strict';
Application.Controllers.controller('AdminProjectsController', ['$scope', '$state', 'Component', 'Licence', 'Theme', 'componentsPromise', 'licencesPromise', 'themesPromise', '_t', 'Member', 'uiTourService', 'settingsPromise', 'growl',
function ($scope, $state, Component, Licence, Theme, componentsPromise, licencesPromise, themesPromise, _t, Member, uiTourService, settingsPromise, growl) {
Application.Controllers.controller('AdminProjectsController', ['$scope', '$state', 'Component', 'Licence', 'Theme', 'ProjectCategory', 'componentsPromise', 'licencesPromise', 'themesPromise', 'projectCategoriesPromise', '_t', 'Member', 'uiTourService', 'settingsPromise', 'growl',
function ($scope, $state, Component, Licence, Theme, ProjectCategory, componentsPromise, licencesPromise, themesPromise, projectCategoriesPromise, _t, Member, uiTourService, settingsPromise, growl) {
// Materials list (plastic, wood ...)
$scope.components = componentsPromise;
@ -23,6 +23,9 @@ Application.Controllers.controller('AdminProjectsController', ['$scope', '$state
// Themes list (cooking, sport ...)
$scope.themes = themesPromise;
// Project categories list (generic categorization)
$scope.projectCategories = projectCategoriesPromise;
// Application settings
$scope.allSettings = settingsPromise;
@ -115,6 +118,49 @@ Application.Controllers.controller('AdminProjectsController', ['$scope', '$state
}
};
/**
* Saves a new project category / Update an existing project category to the server (form validation callback)
* @param data {Object} project category name
* @param [data] {number} project category id, in case of update
*/
$scope.saveProjectCategory = function (data, id) {
if (id != null) {
return ProjectCategory.update({ id }, data);
} else {
return ProjectCategory.save(data, resp => $scope.projectCategories[$scope.projectCategories.length - 1].id = resp.id);
}
};
/**
* Deletes the project category at the specified index
* @param index {number} project category index in the $scope.projectCategories array
*/
$scope.removeProjectCategory = function (index) {
ProjectCategory.delete($scope.projectCategories[index]);
return $scope.projectCategories.splice(index, 1);
};
/**
* Creates a new empty entry in the $scope.projectCategories array
*/
$scope.addProjectCategory = function () {
$scope.inserted = { name: '' };
$scope.projectCategories.push($scope.inserted);
};
/**
* Removes the newly inserted but not saved project category / Cancel the current project category modification
* @param rowform {Object} see http://vitalets.github.io/angular-xeditable/
* @param index {number} project category index in the $scope.projectCategories array
*/
$scope.cancelProjectCategory = function (rowform, index) {
if ($scope.projectCategories[index].id != null) {
rowform.$cancel();
} else {
$scope.projectCategories.splice(index, 1);
}
};
/**
* Saves a new licence / Update an existing licence to the server (form validation callback)
* @param data {Object} licence name and description

View File

@ -29,6 +29,7 @@
* - $scope.themes = [{Theme}]
* - $scope.licences = [{Licence}]
* - $scope.allowedExtensions = [{String}]
* - $scope.projectCategoriesWording = [{String}]
* - $scope.submited(content)
* - $scope.cancel()
* - $scope.addFile()
@ -43,7 +44,7 @@
* - $state (Ui-Router) [ 'app.public.projects_show', 'app.public.projects_list' ]
*/
class ProjectsController {
constructor ($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, _t) {
constructor ($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, ProjectCategory, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, projectCategoriesWording, _t) {
// remove codeview from summernote editor
$scope.summernoteOptsProject = angular.copy($rootScope.summernoteOpts);
$scope.summernoteOptsProject.toolbar[6][1].splice(1, 1);
@ -78,6 +79,16 @@ class ProjectsController {
});
});
// Retrieve the list of themes from the server
ProjectCategory.query().$promise.then(function (data) {
$scope.projectCategories = data.map(function (d) {
return ({
id: d.id,
name: d.name
});
});
});
// Retrieve the list of licences from the server
Licence.query().$promise.then(function (data) {
$scope.licences = data.map(function (d) {
@ -104,6 +115,8 @@ class ProjectsController {
// List of extensions allowed for CAD attachements upload
$scope.allowedExtensions = allowedExtensions.setting.value.split(' ');
$scope.projectCategoriesWording = projectCategoriesWording.setting.value;
/**
* For use with ngUpload (https://github.com/twilson63/ngUpload).
* Intended to be the callback when an upload is done: any raised error will be stacked in the
@ -281,8 +294,8 @@ class ProjectsController {
/**
* Controller used on projects listing page
*/
Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'Project', 'machinesPromise', 'themesPromise', 'componentsPromise', 'paginationService', 'OpenlabProject', '$window', 'growl', '_t', '$location', '$timeout', 'settingsPromise', 'openLabActive', 'Member', 'Diacritics',
function ($scope, $state, Project, machinesPromise, themesPromise, componentsPromise, paginationService, OpenlabProject, $window, growl, _t, $location, $timeout, settingsPromise, openLabActive, Member, Diacritics) {
Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'Project', 'machinesPromise', 'themesPromise', 'projectCategoriesPromise', 'componentsPromise', 'paginationService', 'OpenlabProject', '$window', 'growl', '_t', '$location', '$timeout', 'settingsPromise', 'openLabActive', 'Member', 'Diacritics',
function ($scope, $state, Project, machinesPromise, themesPromise, projectCategoriesPromise, componentsPromise, paginationService, OpenlabProject, $window, growl, _t, $location, $timeout, settingsPromise, openLabActive, Member, Diacritics) {
/* PRIVATE STATIC CONSTANTS */
// Number of projects added to the page when the user clicks on 'load more projects'
@ -297,6 +310,7 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
// settings of optional filters
$scope.memberFilterPresence = settingsPromise.projects_list_member_filter_presence !== 'false';
$scope.dateFiltersPresence = settingsPromise.projects_list_date_filters_presence !== 'false';
$scope.projectCategoriesFilterPlaceholder = settingsPromise.project_categories_filter_placeholder;
// Is openLab enabled on the instance?
$scope.openlab = {
@ -319,6 +333,7 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
component_id: (parseInt($location.$$search.component_id) || undefined),
theme_id: (parseInt($location.$$search.theme_id) || undefined),
status_id: (parseInt($location.$$search.status_id) || undefined),
project_category_id: (parseInt($location.$$search.project_category_id) || undefined),
member_id: (parseInt($location.$$search.member_id) || undefined),
from_date: fromDate,
to_date: toDate
@ -349,6 +364,9 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
// list of themes / used for filtering
$scope.themes = themesPromise;
// list of projectCategories / used for filtering
$scope.projectCategories = projectCategoriesPromise;
// list of components / used for filtering
$scope.components = componentsPromise;
@ -394,6 +412,7 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
$scope.search.member_id = undefined;
$scope.search.from_date = undefined;
$scope.search.to_date = undefined;
$scope.search.project_category_id = undefined;
$scope.$apply();
$scope.setUrlQueryParams($scope.search);
$scope.triggerSearch();
@ -461,6 +480,7 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
updateUrlParam('from_date', fromDate);
const toDate = search.to_date ? search.to_date.toDateString() : undefined;
updateUrlParam('to_date', toDate);
updateUrlParam('project_category_id', search.project_category_id);
return true;
};
@ -551,8 +571,8 @@ Application.Controllers.controller('ProjectsController', ['$scope', '$state', 'P
/**
* Controller used in the project creation page
*/
Application.Controllers.controller('NewProjectController', ['$rootScope', '$scope', '$state', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'Licence', 'Status', '$document', 'CSRF', 'Diacritics', 'dialogs', 'allowedExtensions', '_t',
function ($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, Licence, Status, $document, CSRF, Diacritics, dialogs, allowedExtensions, _t) {
Application.Controllers.controller('NewProjectController', ['$rootScope', '$scope', '$state', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'ProjectCategory', 'Licence', 'Status', '$document', 'CSRF', 'Diacritics', 'dialogs', 'allowedExtensions', 'projectCategoriesWording', '_t',
function ($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, ProjectCategory, Licence, Status, $document, CSRF, Diacritics, dialogs, allowedExtensions, projectCategoriesWording, _t) {
CSRF.setMetaTags();
// API URL where the form will be posted
@ -584,15 +604,15 @@ Application.Controllers.controller('NewProjectController', ['$rootScope', '$scop
};
// Using the ProjectsController
return new ProjectsController($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, _t);
return new ProjectsController($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, ProjectCategory, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, projectCategoriesWording, _t);
}
]);
/**
* Controller used in the project edition page
*/
Application.Controllers.controller('EditProjectController', ['$rootScope', '$scope', '$state', '$transition$', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'Licence', 'Status', '$document', 'CSRF', 'projectPromise', 'Diacritics', 'dialogs', 'allowedExtensions', '_t',
function ($rootScope, $scope, $state, $transition$, Project, Machine, Member, Component, Theme, Licence, Status, $document, CSRF, projectPromise, Diacritics, dialogs, allowedExtensions, _t) {
Application.Controllers.controller('EditProjectController', ['$rootScope', '$scope', '$state', '$transition$', 'Project', 'Machine', 'Member', 'Component', 'Theme', 'ProjectCategory', 'Licence', 'Status', '$document', 'CSRF', 'projectPromise', 'Diacritics', 'dialogs', 'allowedExtensions', 'projectCategoriesWording', '_t',
function ($rootScope, $scope, $state, $transition$, Project, Machine, Member, Component, Theme, ProjectCategory, Licence, Status, $document, CSRF, projectPromise, Diacritics, dialogs, allowedExtensions, projectCategoriesWording, _t) {
/* PUBLIC SCOPE */
// API URL where the form will be posted
@ -638,7 +658,7 @@ Application.Controllers.controller('EditProjectController', ['$rootScope', '$sco
}
// Using the ProjectsController
return new ProjectsController($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, _t);
return new ProjectsController($rootScope, $scope, $state, Project, Machine, Member, Component, Theme, ProjectCategory, Licence, Status, $document, Diacritics, dialogs, allowedExtensions, projectCategoriesWording, _t);
};
// !!! MUST BE CALLED AT THE END of the controller
@ -649,14 +669,15 @@ Application.Controllers.controller('EditProjectController', ['$rootScope', '$sco
/**
* Controller used in the public project's details page
*/
Application.Controllers.controller('ShowProjectController', ['$scope', '$state', 'projectPromise', 'shortnamePromise', '$location', '$uibModal', 'dialogs', '_t',
function ($scope, $state, projectPromise, shortnamePromise, $location, $uibModal, dialogs, _t) {
Application.Controllers.controller('ShowProjectController', ['$scope', '$state', 'projectPromise', 'shortnamePromise', 'projectCategoriesWording', '$location', '$uibModal', 'dialogs', '_t',
function ($scope, $state, projectPromise, shortnamePromise, projectCategoriesWording, $location, $uibModal, dialogs, _t) {
/* PUBLIC SCOPE */
// Store the project's details
$scope.project = projectPromise;
$scope.projectUrl = $location.absUrl();
$scope.disqusShortname = shortnamePromise.setting.value;
$scope.projectCategoriesWording = projectCategoriesWording.setting.value;
/**
* Test if the provided user has the edition rights on the current project

View File

@ -0,0 +1,5 @@
// Type model used in ProjectSettings and its child components
export interface ProjectCategory {
name: string,
id?: number,
}

View File

@ -200,7 +200,9 @@ export const projectsSettings = [
'allowed_cad_mime_types',
'disqus_shortname',
'projects_list_member_filter_presence',
'projects_list_date_filters_presence'
'projects_list_date_filters_presence',
'project_categories_filter_placeholder',
'project_categories_wording'
] as const;
export const prepaidPacksSettings = [
@ -223,7 +225,7 @@ export const pricingSettings = [
'extended_prices_in_same_day'
] as const;
export const poymentSettings = [
export const paymentSettings = [
'payment_gateway'
] as const;
@ -293,7 +295,7 @@ export const allSettings = [
...registrationSettings,
...adminSettings,
...pricingSettings,
...poymentSettings,
...paymentSettings,
...displaySettings,
...storeSettings,
...trainingsSettings,

View File

@ -301,8 +301,9 @@ angular.module('application.router', ['ui.router'])
themesPromise: ['Theme', function (Theme) { return Theme.query().$promise; }],
componentsPromise: ['Component', function (Component) { return Component.query().$promise; }],
machinesPromise: ['Machine', function (Machine) { return Machine.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['openlab_app_id', 'openlab_default', 'projects_list_member_filter_presence', 'projects_list_date_filters_presence']" }).$promise; }],
openLabActive: ['Setting', function (Setting) { return Setting.isPresent({ name: 'openlab_app_secret' }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['openlab_app_id', 'openlab_default', 'projects_list_member_filter_presence', 'projects_list_date_filters_presence', 'project_categories_filter_placeholder']" }).$promise; }],
openLabActive: ['Setting', function (Setting) { return Setting.isPresent({ name: 'openlab_app_secret' }).$promise; }],
projectCategoriesPromise: ['ProjectCategory', function (ProjectCategory) { return ProjectCategory.query().$promise; }]
}
})
.state('app.logged.projects_new', {
@ -314,7 +315,8 @@ angular.module('application.router', ['ui.router'])
}
},
resolve: {
allowedExtensions: ['Setting', function (Setting) { return Setting.get({ name: 'allowed_cad_extensions' }).$promise; }]
allowedExtensions: ['Setting', function (Setting) { return Setting.get({ name: 'allowed_cad_extensions' }).$promise; }],
projectCategoriesWording: ['Setting', function (Setting) { return Setting.get({ name: 'project_categories_wording' }).$promise; }]
}
})
.state('app.public.projects_show', {
@ -327,7 +329,8 @@ angular.module('application.router', ['ui.router'])
},
resolve: {
projectPromise: ['$transition$', 'Project', function ($transition$, Project) { return Project.get({ id: $transition$.params().id }).$promise; }],
shortnamePromise: ['Setting', function (Setting) { return Setting.get({ name: 'disqus_shortname' }).$promise; }]
shortnamePromise: ['Setting', function (Setting) { return Setting.get({ name: 'disqus_shortname' }).$promise; }],
projectCategoriesWording: ['Setting', function (Setting) { return Setting.get({ name: 'project_categories_wording' }).$promise; }]
}
})
.state('app.logged.projects_edit', {
@ -340,7 +343,8 @@ angular.module('application.router', ['ui.router'])
},
resolve: {
projectPromise: ['$transition$', 'Project', function ($transition$, Project) { return Project.get({ id: $transition$.params().id }).$promise; }],
allowedExtensions: ['Setting', function (Setting) { return Setting.get({ name: 'allowed_cad_extensions' }).$promise; }]
allowedExtensions: ['Setting', function (Setting) { return Setting.get({ name: 'allowed_cad_extensions' }).$promise; }],
projectCategoriesWording: ['Setting', function (Setting) { return Setting.get({ name: 'project_categories_wording' }).$promise; }]
}
})
@ -735,10 +739,11 @@ angular.module('application.router', ['ui.router'])
componentsPromise: ['Component', function (Component) { return Component.query().$promise; }],
licencesPromise: ['Licence', function (Licence) { return Licence.query().$promise; }],
themesPromise: ['Theme', function (Theme) { return Theme.query().$promise; }],
projectCategoriesPromise: ['ProjectCategory', function (ProjectCategory) { return ProjectCategory.query().$promise; }],
settingsPromise: ['Setting', function (Setting) {
return Setting.query({
names: "['feature_tour_display', 'disqus_shortname', 'allowed_cad_extensions', " +
"'allowed_cad_mime_types', 'openlab_app_id', 'openlab_app_secret', 'openlab_default']"
"'allowed_cad_mime_types', 'openlab_app_id', 'openlab_app_secret', 'openlab_default', 'project_categories_filter_placeholder', 'project_categories_wording']"
}).$promise;
}]
}

View File

@ -0,0 +1,11 @@
'use strict';
Application.Services.factory('ProjectCategory', ['$resource', function ($resource) {
return $resource('/api/project_categories/:id',
{ id: '@id' }, {
update: {
method: 'PUT'
}
}
);
}]);

View File

@ -42,7 +42,10 @@
<uib-tab heading="{{ 'app.admin.projects.statuses' | translate }}" index="3">
<status-settings on-error="onError" on-success="onSuccess"/>
</uib-tab>
<uib-tab heading="{{ 'app.admin.projects.settings.title' | translate }}" index="4" class="settings-tab">
<uib-tab heading="{{ 'app.admin.projects.project_categories' | translate }}" index="4">
<ng-include src="'/admin/projects/project_categories.html'"></ng-include>
</uib-tab>
<uib-tab heading="{{ 'app.admin.projects.settings.title' | translate }}" index="5" class="settings-tab">
<ng-include src="'/admin/projects/settings.html'"></ng-include>
</uib-tab>
</uib-tabset>

View File

@ -0,0 +1,38 @@
<button type="button" class="btn btn-warning m-t m-b" ng-click="addProjectCategory()" translate>{{ 'app.admin.projects.add_a_new_project_category' }}</button>
<table class="table">
<thead>
<tr>
<th style="width:80%" translate>{{ 'app.admin.project_categories.name' }}</th>
<th style="width:20%"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="projectCategory in projectCategories">
<td>
<span editable-text="projectCategory.name" e-name="name" e-form="rowform" e-required>
{{ projectCategory.name }}
</span>
</td>
<td>
<!-- form -->
<form editable-form name="rowform" onbeforesave="saveProjectCategory($data, projectCategory.id)" ng-show="rowform.$visible" class="form-buttons form-inline" shown="inserted == projectCategory">
<button type="submit" ng-disabled="rowform.$waiting" class="btn btn-warning">
<i class="fa fa-check"></i>
</button>
<button type="button" ng-disabled="rowform.$waiting" ng-click="cancelProjectCategory(rowform, $index)" class="btn btn-default">
<i class="fa fa-times"></i>
</button>
</form>
<div class="buttons" ng-show="!rowform.$visible">
<button class="btn btn-default" ng-click="rowform.$show()">
<i class="fa fa-edit"></i> <span class="hidden-xs hidden-sm" translate>{{ 'app.shared.buttons.edit' }}</span>
</button>
<button class="btn btn-danger" ng-click="removeProjectCategory($index)">
<i class="fa fa-trash-o"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>

View File

@ -117,3 +117,27 @@
</div>
</div>
</div>
<div class="panel panel-default m-t-lg">
<div class="panel-heading">
<span class="font-sbold" translate>{{ 'app.admin.projects.settings.project_categories' }}</span>
</div>
<div class="panel-body">
<div class="row">
<div class="col-md-6">
<text-setting name="project_categories_filter_placeholder"
settings="allSettings"
label="app.admin.settings.project_categories_filter_placeholder">
</text-setting>
</div>
</div>
<div class="row m-t">
<div class="col-md-6">
<text-setting name="project_categories_wording"
settings="allSettings"
label="app.admin.settings.project_categories_wording">
</text-setting>
</div>
</div>
</div>
</div>

View File

@ -279,6 +279,23 @@
</div>
</div>
<div class="widget panel b-a m m-t-lg">
<div class="panel-heading b-b small">
<h3 translate>{{ projectCategoriesWording }}</h3>
</div>
<div class="widget-content no-bg wrapper">
<input type="hidden" name="project[project_category_ids][]" value="" />
<ui-select multiple ng-model="project.project_category_ids" class="form-control">
<ui-select-match>
<span ng-bind="$item.name"></span>
<input type="hidden" name="project[project_category_ids][]" value="{{$item.id}}" />
</ui-select-match>
<ui-select-choices repeat="pc.id as pc in (projectCategories | filter: $select.search)">
<span ng-bind-html="pc.name | highlight: $select.search"></span>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
</div>

View File

@ -61,6 +61,10 @@
<option value="" translate>{{ 'app.public.projects_list.all_themes' }}</option>
</select>
<select ng-model="search.project_category_id" ng-change="setUrlQueryParams(search) && triggerSearch()" class="form-control" ng-options="pc.id as pc.name for pc in projectCategories">
<option value="" translate>{{ projectCategoriesFilterPlaceholder }}</option>
</select>
<select ng-model="search.component_id" ng-change="setUrlQueryParams(search) && triggerSearch()" class="form-control" ng-options="t.id as t.name for t in components">
<option value="" translate>{{ 'app.public.projects_list.all_materials' }}</option>
</select>

View File

@ -174,12 +174,24 @@
</div>
</section>
<section class="widget b-t">
<section class="widget panel b-a m" ng-if="project.project_categories">
<div class="panel-heading b-b">
<h3 translate>{{ projectCategoriesWording }}</h3>
</div>
<div class="widget-content text-center m-t">
<a ng-click="signalAbuse($event)"><i class="fa fa-warning"></i> {{ 'app.public.projects_show.report_an_abuse' | translate }}</a>
</div>
</section>
<ul class="widget-content list-group list-group-lg no-bg auto">
<li ng-repeat="projectCategory in project.project_categories" class="list-group-item no-b clearfix">
{{projectCategory.name}}
</li>
</ul>
</section>
<section class="widget b-t">
<div class="widget-content text-center m-t">
<a ng-click="signalAbuse($event)"><i class="fa fa-warning"></i> {{ 'app.public.projects_show.report_an_abuse' | translate }}</a>
</div>
</section>
</div>
</div>

View File

@ -198,6 +198,8 @@ module SettingsHelper
events_banner_cta_url
projects_list_member_filter_presence
projects_list_date_filters_presence
project_categories_filter_placeholder
project_categories_wording
].freeze
end
# rubocop:enable Metrics/ModuleLength

View File

@ -41,6 +41,8 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :project_steps, allow_destroy: true
has_many :abuses, as: :signaled, dependent: :destroy, class_name: 'Abuse'
has_many :projects_project_categories, dependent: :destroy
has_many :project_categories, through: :projects_project_categories
# validations
validates :author, :name, presence: true
@ -68,6 +70,7 @@ class Project < ApplicationRecord
scope :with_component, ->(component_ids) { joins(:projects_components).where(projects_components: { component_id: component_ids }) }
scope :with_space, ->(spaces_ids) { joins(:projects_spaces).where(projects_spaces: { space_id: spaces_ids }) }
scope :with_status, ->(statuses_ids) { where(status_id: statuses_ids) }
scope :with_project_category, ->(project_category_ids) { joins(:projects_project_categories).where(projects_project_categories: { project_category_id: project_category_ids }) }
pg_search_scope :search,
against: :search_vector,
using: {

View File

@ -0,0 +1,6 @@
class ProjectCategory < ApplicationRecord
validates :name, presence: true
has_many :projects_project_categories, dependent: :destroy
has_many :projects, through: :projects_project_categories
end

View File

@ -0,0 +1,4 @@
class ProjectsProjectCategory < ApplicationRecord
belongs_to :project
belongs_to :project_category
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Check if user is an admin to allow create, update and destroy project_category
class ProjectCategoryPolicy < ApplicationPolicy
def create?
user.admin?
end
def update?
create?
end
def destroy?
create?
end
end

View File

@ -46,7 +46,8 @@ class SettingPolicy < ApplicationPolicy
external_id machines_banner_active machines_banner_text machines_banner_cta_active machines_banner_cta_label
machines_banner_cta_url trainings_banner_active trainings_banner_text trainings_banner_cta_active trainings_banner_cta_label
trainings_banner_cta_url events_banner_active events_banner_text events_banner_cta_active events_banner_cta_label
events_banner_cta_url projects_list_member_filter_presence projects_list_date_filters_presence]
events_banner_cta_url projects_list_member_filter_presence projects_list_date_filters_presence
project_categories_filter_placeholder project_categories_wording]
end
##

View File

@ -21,6 +21,7 @@ class ProjectService
records = records.with_machine(query_params['machine_id']) if query_params['machine_id'].present?
records = records.with_component(query_params['component_id']) if query_params['component_id'].present?
records = records.with_theme(query_params['theme_id']) if query_params['theme_id'].present?
records = records.with_project_category(query_params['project_category_id']) if query_params['project_category_id'].present?
records = records.with_space(query_params['space_id']) if query_params['space_id'].present?
records = records.with_status(query_params['status_id']) if query_params['status_id'].present?

View File

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

View File

@ -7,6 +7,7 @@ json.user_ids project.user_ids
json.machine_ids project.machine_ids
json.theme_ids project.theme_ids
json.component_ids project.component_ids
json.project_category_ids project.project_category_ids
json.tags project.tags
json.name project.name
json.description project.description

View File

@ -39,6 +39,11 @@ json.themes @project.themes do |t|
json.id t.id
json.name t.name
end
json.project_category_ids @project.project_category_ids
json.project_categories @project.project_categories do |t|
json.id t.id
json.name t.name
end
json.user_ids @project.user_ids
json.project_users @project.project_users do |pu|
json.id pu.user.id

View File

@ -415,6 +415,8 @@ en:
add_a_material: "Add a material"
themes: "Themes"
add_a_new_theme: "Add a new theme"
project_categories: "Categories"
add_a_new_project_category: "Add a new category"
licences: "Licences"
statuses: "Statuses"
description: "Description"
@ -446,6 +448,9 @@ en:
openlab_default_info_html: "In the projects gallery, visitors can switch between two views: all shared projects from the whole OpenLab network, or only the projects documented in your Fab Lab.<br/>Here, you can choose which view is shown by default."
default_to_openlab: "Display OpenLab by default"
filters: Projects list filters
project_categories: Categories
project_categories:
name: "Name"
projects_setting:
add: "Add"
actions_controls: "Actions"
@ -1776,6 +1781,8 @@ en:
show_username_in_admin_list: "Show the username in the list"
projects_list_member_filter_presence: "Presence of member filter on projects list"
projects_list_date_filters_presence: "Presence of date filters on projects list"
project_categories_filter_placeholder: "Placeholder for categories filter in project gallery"
project_categories_wording: "Wording used to replace \"Categories\" on public pages"
overlapping_options:
training_reservations: "Trainings"
machine_reservations: "Machines"

View File

@ -415,6 +415,8 @@ fr:
add_a_material: "Ajouter un matériau"
themes: "Thématiques"
add_a_new_theme: "Ajouter une nouvelle thématique"
project_categories: "Catégories"
add_a_new_project_category: "Ajouter une nouvelle catégorie"
licences: "Licences"
statuses: "Statuts"
description: "Description"
@ -446,6 +448,9 @@ fr:
openlab_default_info_html: "Dans la galerie de projets, les visiteurs peuvent choisir entre deux vues : tous les projets de l'ensemble du réseau OpenLab, ou uniquement les projets documentés dans votre Fab Lab.<br/>Ici, vous pouvez choisir quelle vue est affichée par défaut."
default_to_openlab: "Afficher OpenLab par défaut"
filters: Filtres de la vue liste
project_categories: Catégories
project_categories:
name: "Nom"
projects_setting:
add: "Ajouter"
actions_controls: "Actions"
@ -1776,6 +1781,8 @@ fr:
show_username_in_admin_list: "Afficher le nom d'utilisateur dans la liste"
projects_list_member_filter_presence: "Présence du filtre par membre dans la vue liste des projets"
projects_list_date_filters_presence: "Présence des filtres par date dans la vue liste des projets"
project_categories_filter_placeholder: "Texte du filtre par catégories de la galerie de projets"
project_categories_wording: "Mot utilisé en remplacement du mot \"Catégories\" sur les pages publiques"
overlapping_options:
training_reservations: "Formations"
machine_reservations: "Machines"

View File

@ -699,6 +699,8 @@ en:
trainings_invalidation_rule_period: "Grace period before invalidating a training"
projects_list_member_filter_presence: "Presence of member filter on projects list"
projects_list_date_filters_presence: "Presence of dates filter on projects list"
project_categories_filter_placeholder: "Placeholder for categories filter in project gallery"
project_categories_wording: "Wording used to replace \"Categories\" on public pages"
#statuses of projects
statuses:
new: "New"

View File

@ -699,6 +699,8 @@ fr:
trainings_invalidation_rule_period: "Période de grâce avant d'invalider une formation"
projects_list_member_filter_presence: "Présence du filtre par membre dans la vue liste des projets"
projects_list_date_filters_presence: "Présence des filtres par date dans la vue liste des projets"
project_categories_filter_placeholder: "Texte du filtre par catégories de la galerie de projets"
project_categories_wording: "Mot utilisé en remplacement du mot \"Catégories\" sur les pages publiques"
#statuses of projects
statuses:
new: "Nouveau"

View File

@ -46,6 +46,7 @@ Rails.application.routes.draw do
resources :themes
resources :licences
resources :statuses
resources :project_categories
resources :admins, only: %i[index create destroy]
resources :settings, only: %i[show update index], param: :name do
patch '/bulk_update', action: 'bulk_update', on: :collection

View File

@ -0,0 +1,9 @@
class CreateProjectCategories < ActiveRecord::Migration[7.0]
def change
create_table :project_categories do |t|
t.string :name
t.timestamps
end
end
end

View File

@ -0,0 +1,12 @@
class CreateProjectsProjectCategories < ActiveRecord::Migration[7.0]
def change
create_table :projects_project_categories do |t|
t.belongs_to :project, foreign_key: true, null: false
t.belongs_to :project_category, foreign_key: true, null: false
t.timestamps
end
add_index :projects_project_categories, [:project_id, :project_category_id], unique: true, name: :idx_projects_project_categories
end
end

View File

@ -731,3 +731,5 @@ Setting.set('external_id', false) unless Setting.find_by(name: 'external_id').tr
Setting.set('projects_list_member_filter_presence', false) unless Setting.find_by(name: 'projects_list_member_filter_presence')
Setting.set('projects_list_date_filters_presence', false) unless Setting.find_by(name: 'projects_list_date_filters_presence')
Setting.set('project_categories_filter_placeholder', 'Toutes les catégories') unless Setting.find_by(name: 'project_categories_filter_placeholder').try(:value)
Setting.set('project_categories_wording', 'Catégories') unless Setting.find_by(name: 'project_categories_wording').try(:value)

View File

@ -9,13 +9,6 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- *not* creating schema, since initdb creates it
--
-- Name: fuzzystrmatch; Type: EXTENSION; Schema: -; Owner: -
--
@ -2725,6 +2718,37 @@ CREATE SEQUENCE public.profiles_id_seq
ALTER SEQUENCE public.profiles_id_seq OWNED BY public.profiles.id;
--
-- Name: project_categories; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.project_categories (
id bigint NOT NULL,
name character varying,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: project_categories_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.project_categories_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: project_categories_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.project_categories_id_seq OWNED BY public.project_categories.id;
--
-- Name: project_steps; Type: TABLE; Schema: public; Owner: -
--
@ -2893,6 +2917,38 @@ CREATE SEQUENCE public.projects_machines_id_seq
ALTER SEQUENCE public.projects_machines_id_seq OWNED BY public.projects_machines.id;
--
-- Name: projects_project_categories; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.projects_project_categories (
id bigint NOT NULL,
project_id bigint NOT NULL,
project_category_id bigint NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: projects_project_categories_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.projects_project_categories_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: projects_project_categories_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.projects_project_categories_id_seq OWNED BY public.projects_project_categories.id;
--
-- Name: projects_spaces; Type: TABLE; Schema: public; Owner: -
--
@ -4746,6 +4802,13 @@ ALTER TABLE ONLY public.profile_custom_fields ALTER COLUMN id SET DEFAULT nextva
ALTER TABLE ONLY public.profiles ALTER COLUMN id SET DEFAULT nextval('public.profiles_id_seq'::regclass);
--
-- Name: project_categories id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.project_categories ALTER COLUMN id SET DEFAULT nextval('public.project_categories_id_seq'::regclass);
--
-- Name: project_steps id; Type: DEFAULT; Schema: public; Owner: -
--
@ -4781,6 +4844,13 @@ ALTER TABLE ONLY public.projects_components ALTER COLUMN id SET DEFAULT nextval(
ALTER TABLE ONLY public.projects_machines ALTER COLUMN id SET DEFAULT nextval('public.projects_machines_id_seq'::regclass);
--
-- Name: projects_project_categories id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.projects_project_categories ALTER COLUMN id SET DEFAULT nextval('public.projects_project_categories_id_seq'::regclass);
--
-- Name: projects_spaces id; Type: DEFAULT; Schema: public; Owner: -
--
@ -5646,6 +5716,14 @@ ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
--
-- Name: project_categories project_categories_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.project_categories
ADD CONSTRAINT project_categories_pkey PRIMARY KEY (id);
--
-- Name: project_steps project_steps_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -5686,6 +5764,14 @@ ALTER TABLE ONLY public.projects
ADD CONSTRAINT projects_pkey PRIMARY KEY (id);
--
-- Name: projects_project_categories projects_project_categories_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.projects_project_categories
ADD CONSTRAINT projects_project_categories_pkey PRIMARY KEY (id);
--
-- Name: projects_spaces projects_spaces_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -5998,6 +6084,13 @@ ALTER TABLE ONLY public.wallets
ADD CONSTRAINT wallets_pkey PRIMARY KEY (id);
--
-- Name: idx_projects_project_categories; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX idx_projects_project_categories ON public.projects_project_categories USING btree (project_id, project_category_id);
--
-- Name: index_abuses_on_signaled_type_and_signaled_id; Type: INDEX; Schema: public; Owner: -
--
@ -6894,6 +6987,20 @@ CREATE UNIQUE INDEX index_projects_on_slug ON public.projects USING btree (slug)
CREATE INDEX index_projects_on_status_id ON public.projects USING btree (status_id);
--
-- Name: index_projects_project_categories_on_project_category_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_projects_project_categories_on_project_category_id ON public.projects_project_categories USING btree (project_category_id);
--
-- Name: index_projects_project_categories_on_project_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_projects_project_categories_on_project_id ON public.projects_project_categories USING btree (project_id);
--
-- Name: index_projects_spaces_on_project_id; Type: INDEX; Schema: public; Owner: -
--
@ -8057,6 +8164,14 @@ ALTER TABLE ONLY public.projects
ADD CONSTRAINT fk_rails_b4a83cd9b3 FOREIGN KEY (status_id) REFERENCES public.statuses(id);
--
-- Name: projects_project_categories fk_rails_ba4a985e85; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.projects_project_categories
ADD CONSTRAINT fk_rails_ba4a985e85 FOREIGN KEY (project_id) REFERENCES public.projects(id);
--
-- Name: statistic_profiles fk_rails_bba64e5eb9; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -8209,6 +8324,14 @@ ALTER TABLE ONLY public.event_price_categories
ADD CONSTRAINT fk_rails_dcd2787d07 FOREIGN KEY (event_id) REFERENCES public.events(id);
--
-- Name: projects_project_categories fk_rails_de9f22810e; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.projects_project_categories
ADD CONSTRAINT fk_rails_de9f22810e FOREIGN KEY (project_category_id) REFERENCES public.project_categories(id);
--
-- Name: cart_item_coupons fk_rails_e1cb402fac; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@ -8693,6 +8816,8 @@ INSERT INTO "schema_migrations" (version) VALUES
('20230324095639'),
('20230328094807'),
('20230328094808'),
('20230328094809');
('20230328094809'),
('20230626122844'),
('20230626122947');

View File

@ -852,6 +852,20 @@ history_value_100:
updated_at: 2023-04-05 09:16:08.000511500 Z
invoicing_profile_id: 1
history_value_101:
setting_id: 100
value: 'Toutes les catégories'
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
invoicing_profile_id: 1
history_value_102:
setting_id: 101
value: 'Catégories'
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
invoicing_profile_id: 1
history_value_103:
id: 103
setting_id: 102
@ -866,4 +880,4 @@ history_value_104:
value: 'false'
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
invoicing_profile_id: 1
invoicing_profile_id: 1

12
test/fixtures/project_categories.yml vendored Normal file
View File

@ -0,0 +1,12 @@
project_category_1:
id: 1
name: Module 1
created_at: 2023-06-26 15:39:08.259759000 Z
updated_at: 2016-06-26 15:39:08.259759000 Z
project_category_2:
id: 2
name: Module 2
created_at: 2016-06-26 15:39:08.265840000 Z
updated_at: 2016-06-26 15:39:08.265840000 Z

View File

@ -0,0 +1,7 @@
projects_project_category_1:
id: 1
project_id: 1
project_category_id: 1
created_at: 2023-06-26 15:39:08.259759000 Z
updated_at: 2016-06-26 15:39:08.259759000 Z

View File

@ -587,6 +587,18 @@ setting_99:
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
setting_100:
id: 100
name: project_categories_filter_placeholder
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
setting_101:
id: 101
name: project_categories_wording
created_at: 2023-04-05 09:16:08.000511500 Z
updated_at: 2023-04-05 09:16:08.000511500 Z
setting_102:
id: 102
name: projects_list_member_filter_presence

View File

@ -837,6 +837,18 @@ export const settings: Array<Setting> = [
value: 'false',
last_update: '2022-12-23T14:39:12+0100',
localized: 'Projects list date filters presence'
},
{
name: 'project_categories_filter_placeholder',
value: 'Toutes les catégories',
last_update: '2022-12-23T14:39:12+0100',
localized: 'Placeholder for categories filter in project gallery'
},
{
name: 'project_categories_wording',
value: 'Catégories',
last_update: '2022-12-23T14:39:12+0100',
localized: 'Project categories overridden name'
}
];

View File

@ -0,0 +1,68 @@
# frozen_string_literal: true
require 'test_helper'
class ProjectCategoriesTest < ActionDispatch::IntegrationTest
def setup
@admin = User.find_by(username: 'admin')
login_as(@admin, scope: :user)
end
test 'create a project_category' do
post '/api/project_categories',
params: {
name: 'Module de fou'
}.to_json,
headers: default_headers
# Check response format & project_category
assert_equal 201, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the correct project_category was created
res = json_response(response.body)
project_category = ProjectCategory.where(id: res[:id]).first
assert_not_nil project_category, 'project_category was not created in database'
assert_equal 'Module de fou', res[:name]
end
test 'update a project_category' do
patch '/api/project_categories/1',
params: {
name: 'Nouveau nom'
}.to_json,
headers: default_headers
# Check response format & project_category
assert_equal 200, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the project_category was updated
res = json_response(response.body)
assert_equal 1, res[:id]
assert_equal 'Nouveau nom', res[:name]
end
test 'list all project_categories' do
get '/api/project_categories'
# Check response format & project_category
assert_equal 200, response.status, response.body
assert_match Mime[:json].to_s, response.content_type
# Check the list items are ok
project_categories = json_response(response.body)
assert_equal ProjectCategory.count, project_categories.count
end
test 'delete a project_category' do
project_category = ProjectCategory.create!(name: 'Gone too soon')
delete "/api/project_categories/#{project_category.id}"
assert_response :success
assert_empty response.body
assert_raise ActiveRecord::RecordNotFound do
project_category.reload
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'test_helper'
class ProjectCategoryTest < ActiveSupport::TestCase
test 'fixtures are valid' do
ProjectCategory.find_each do |project_category|
assert project_category.valid?
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'test_helper'
class ProjectTest < ActiveSupport::TestCase
test 'fixtures are valid' do
Project.find_each do |project|
assert project.valid?
end
end
test 'relation project_categories' do
assert_equal [project_categories(:project_category_1)], projects(:project_1).project_categories
end
end