WIP: Ajouter la possibilité d'exporter l'estimation en PDF #1

Draft
tcornaut wants to merge 4 commits from feature/export-pdf into develop
17 changed files with 151 additions and 31 deletions
Showing only changes of commit 6ebe4c90d7 - Show all commits

View File

@ -1,13 +1,13 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
editIcon: string;
editableText: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -1,12 +1,12 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
footer: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -1,12 +1,12 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
header: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -1,13 +1,13 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
tabContent: string;
tabs: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -1,13 +1,13 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
home: string;
noProjects: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -1,12 +1,12 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
notFound: string;
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -8,6 +8,8 @@ import { useProjectReducer, addTask, updateTaskEstimation, removeTask, updateTas
import { Task, TaskID, EstimationConfidence } from "../../models/task";
import TimePreview from "../project/time-preview";
import RepartitionPreview from "../project/repartition-preview";
import FinancialPreview from "../project/financial-preview";
import { getHideFinancialPreviewOnPrint } from "../../models/params";
export interface PdfProps {
projectId: string
@ -20,14 +22,21 @@ const Pdf: FunctionalComponent<PdfProps> = ({ projectId }) => {
return (
<div class={`container ${style.pdf}`}>
<div class="column is-9">
<TaskTable
project={project}
readonly={true} />
<div class="columns">
<div class="column is-9">
<TaskTable
project={project}
readonly={true} />
</div>
<div class="column is-3">
<TimePreview project={project} />
<RepartitionPreview project={project} />
</div>
</div>
<div class="column is-3">
<TimePreview project={project} />
<RepartitionPreview project={project} />
<div class="columns">
<div class={`column ${getHideFinancialPreviewOnPrint(project) ? 'noPrint': ''}`}>
<FinancialPreview project={project} />
</div>
</div>
</div>
);

View File

@ -1,4 +1,4 @@
declare namespace StyleModuleCssModule {
declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss {
estimation: string;
mainColumn: string;
@ -9,9 +9,9 @@ declare namespace StyleModuleCssModule {
}
}
declare const StyleModuleCssModule: StyleModuleCssModule.IStyleModuleCss & {
declare const StyleModuleCssModule: StyleModuleCssNamespace.IStyleModuleCss & {
/** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss;
locals: StyleModuleCssNamespace.IStyleModuleCss;
};
export = StyleModuleCssModule;

View File

@ -91,7 +91,10 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<table class={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
<thead>
<tr>
{
readonly ? '' :
<th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
}
<th class={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</th>
@ -109,6 +112,8 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
const categoryLabel = category ? category.label : '???';
return (
<tr key={`taks-${t.id}`}>
{
readonly ? '' :
<td class={`is-narrow noPrint`}>
<button
onClick={onTaskRemoveClick.bind(null, t.id)}
@ -116,6 +121,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
🗑
</button>
</td>
}
<td class={style.mainColumn}>
<EditableText
render={(value) => (<span>{value}</span>)}
@ -166,7 +172,9 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<tfoot>
<tr>
<td class={`${style.noBorder} noPrint`}></td>
<td colSpan={2} class={readonly ? style.noBorder : ''}>
<td colSpan={readonly ? 1 : 2} class={readonly ? style.noBorder : ''}>
{
readonly ? '' :
<div class="field has-addons noPrint">
<p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle tâche"
@ -191,6 +199,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
</a>
</p>
</div>
}
</td>
<th colSpan={3}>Total</th>
</tr>

View File

@ -27,6 +27,9 @@ module.exports = {
proxy: {
'/api': {
target: 'http://127.0.0.1:8081',
},
'/export': {
target: 'http://127.0.0.1:8081',
}
}
},

View File

@ -3,6 +3,7 @@ module forge.cadoles.com/wpetit/guesstimate
go 1.14
require (
github.com/SebastiaanKlippert/go-wkhtmltopdf v1.5.0
github.com/asdine/storm/v3 v3.1.1
github.com/caarlos0/env/v6 v6.2.1
github.com/davecgh/go-spew v1.1.1 // indirect

View File

@ -19,6 +19,8 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/SebastiaanKlippert/go-wkhtmltopdf v1.5.0 h1:KNMkSqhko6c7eVncl/laVCS95jRryULVhVwwL0VynnU=
github.com/SebastiaanKlippert/go-wkhtmltopdf v1.5.0/go.mod h1:zRBvVJtIfhaWvQ9lPIkXrtona4qqSmjZ1HfKvq4dQzI=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=

View File

@ -10,16 +10,27 @@ import (
"gopkg.in/yaml.v2"
)
// Config is the configuration struct
type Config struct {
HTTP HTTPConfig `yaml:"http"`
Data DataConfig `ymal:"data"`
HTTP HTTPConfig `yaml:"http"`
Client ClientConfig `yaml:"client"`
Data DataConfig `ymal:"data"`
}
// HTTPConfig is the configuration part which defines HTTP related params
type HTTPConfig struct {
BaseURL string `yaml:"baseurl" env:"GUESSTIMATE_BASE_URL"`
Address string `yaml:"address" env:"GUESSTIMATE_HTTP_ADDRESS"`
PublicDir string `yaml:"publicDir" env:"GUESSTIMATE_PUBLIC_DIR"`
}
// ClientConfig is the configuration part which defines the client app related params
type ClientConfig struct {
BaseURL string `yaml:"baseurl" env:"GUESSTIMATE_CLIENT_BASE_URL"`
Address string `yaml:"address" env:"GUESSTIMATE_CLIENT_HTTP_ADDRESS"`
}
// DataConfig is the configuration part which defines data related params
type DataConfig struct {
Path string `yaml:"path" env:"GUESSTIMATE_DATA_PATH"`
}
@ -40,6 +51,7 @@ func NewFromFile(filepath string) (*Config, error) {
return config, nil
}
// WithEnvironment retrieves the configuration from env vars
func WithEnvironment(conf *Config) error {
if err := env.Parse(conf); err != nil {
return err
@ -48,23 +60,31 @@ func WithEnvironment(conf *Config) error {
return nil
}
// NewDumpDefault retrieves the default configuration
func NewDumpDefault() *Config {
config := NewDefault()
return config
}
// NewDefault creates and returns a new default configuration
func NewDefault() *Config {
return &Config{
HTTP: HTTPConfig{
BaseURL: "localhost",
Address: ":8081",
PublicDir: "public",
},
Client: ClientConfig{
BaseURL: "localhost",
Address: ":8080",
},
Data: DataConfig{
Path: "guesstimate.db",
},
}
}
// Dump writes a given config to a config file
func Dump(config *Config, w io.Writer) error {
data, err := yaml.Marshal(config)
if err != nil {

View File

@ -2,6 +2,7 @@ package config
import "gitlab.com/wpetit/goweb/service"
// ServiceProvider returns the current config service
func ServiceProvider(config *Config) service.Provider {
return func(ctn *service.Container) (interface{}, error) {
return config, nil

View File

@ -5,6 +5,7 @@ import (
"gitlab.com/wpetit/goweb/service"
)
// ServiceName defines the project's service
const ServiceName service.Name = "config"
// From retrieves the config service in the given container

View File

@ -9,6 +9,7 @@ import (
"gitlab.com/wpetit/goweb/static"
)
// Mount endoints for server app
func Mount(r *chi.Mux, config *config.Config) error {
r.Route("/api/v1", func(r chi.Router) {
r.Get("/projects/{projectID}", handleGetProject)
@ -17,6 +18,10 @@ func Mount(r *chi.Mux, config *config.Config) error {
r.Delete("/projects/{projectID}", handleDeleteProject)
})
r.Route("/export", func(r chi.Router) {
r.Get("/projects/{projectID}", handleExportProject)
})
clientIndex := path.Join(config.HTTP.PublicDir, "index.html")
serveClientIndex := func(w http.ResponseWriter, r *http.Request) {
@ -24,6 +29,7 @@ func Mount(r *chi.Mux, config *config.Config) error {
}
r.Get("/p/*", serveClientIndex)
r.Get("/pdf/*", serveClientIndex)
notFoundHandler := r.NotFoundHandler()
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))

View File

@ -2,14 +2,17 @@ package route
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
jsonpatch "gopkg.in/evanphx/json-patch.v4"
"forge.cadoles.com/wpetit/guesstimate/internal/config"
"forge.cadoles.com/wpetit/guesstimate/internal/model"
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
"github.com/SebastiaanKlippert/go-wkhtmltopdf"
"github.com/go-chi/chi"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
@ -74,6 +77,62 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
}
}
func handleExportProject(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
cfg := config.Must(ctn)
projectID := getProjectID(r)
var (
err error
url string
)
url = "http://" + string(cfg.Client.BaseURL) + string(cfg.Client.Address) + "/pdf/" + string(projectID)

Je ne comprend pas très bien la nécessité du découpage BaseURL et Address. Au final, est ce qu'il ne serait pas plus simple d'avoir un attribut de configuration PublicBaseURL qui comprendrait toute la racine publique de l'application ? Par exemple https://guesstimate.dev.lookingfora.name ?

Je ne comprend pas très bien la nécessité du découpage `BaseURL` et `Address`. Au final, est ce qu'il ne serait pas plus simple d'avoir un attribut de configuration `PublicBaseURL` qui comprendrait toute la racine publique de l'application ? Par exemple `https://guesstimate.dev.lookingfora.name` ?
fmt.Println(url)
// Create new PDF generator
pdfg, err := wkhtmltopdf.NewPDFGenerator()
if err != nil {
log.Fatal(err)
}
// Set global options
pdfg.Dpi.Set(300)
pdfg.Orientation.Set(wkhtmltopdf.OrientationPortrait)
pdfg.Grayscale.Set(true)
// Create a new input page from an URL
page := wkhtmltopdf.NewPage(url)
// Set options for this page
page.FooterRight.Set("[page]")
page.FooterFontSize.Set(10)
page.Zoom.Set(0.95)
// Add to document
pdfg.AddPage(page)
// Create PDF document in internal buffer
err = pdfg.Create()
if err != nil {
log.Fatal(err)
}
// Write buffer contents to file on disk
err = pdfg.WriteFile("./sample.pdf")
if err != nil {
log.Fatal(err)
}
rsp, err := writePDF(w, http.StatusOK, pdfg.Bytes())
if err != nil {
panic(errors.Wrap(err, "could not write pdf response"))
}
fmt.Println(rsp)
}
type createRequest struct {
Project *model.Project `json:"project"`
}
@ -246,3 +305,12 @@ func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
return encoder.Encode(data)
}
func writePDF(w http.ResponseWriter, statusCode int, data []byte) (int, error) {
w.Header().Set("Content-Disposition", "attachment; filename=foo.pdf")
w.Header().Set("Content-Type", "application/pdf")
w.WriteHeader(statusCode)
return w.Write(data)
}