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 { export interface IStyleModuleCss {
editIcon: string; editIcon: string;
editableText: 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` */ /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss; locals: StyleModuleCssNamespace.IStyleModuleCss;
}; };
export = StyleModuleCssModule; export = StyleModuleCssModule;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
declare namespace StyleModuleCssModule { declare namespace StyleModuleCssNamespace {
export interface IStyleModuleCss { export interface IStyleModuleCss {
estimation: string; estimation: string;
mainColumn: 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` */ /** WARNING: Only available when `css-loader` is used without `style-loader` or `mini-css-extract-plugin` */
locals: StyleModuleCssModule.IStyleModuleCss; locals: StyleModuleCssNamespace.IStyleModuleCss;
}; };
export = StyleModuleCssModule; 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}`}> <table class={`table is-bordered is-striped is-hoverable is-fullwidth ${style.middleTable}`}>
<thead> <thead>
<tr> <tr>
{
readonly ? '' :
<th class={`${style.noBorder} noPrint`} rowSpan={2}></th> <th class={`${style.noBorder} noPrint`} rowSpan={2}></th>
}
<th class={style.mainColumn} rowSpan={2}>Tâche</th> <th class={style.mainColumn} rowSpan={2}>Tâche</th>
<th rowSpan={2}>Catégorie</th> <th rowSpan={2}>Catégorie</th>
<th colSpan={3}>Estimation (en <ProjectTimeUnit project={project} />)</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 : '???'; const categoryLabel = category ? category.label : '???';
return ( return (
<tr key={`taks-${t.id}`}> <tr key={`taks-${t.id}`}>
{
readonly ? '' :
<td class={`is-narrow noPrint`}> <td class={`is-narrow noPrint`}>
<button <button
onClick={onTaskRemoveClick.bind(null, t.id)} onClick={onTaskRemoveClick.bind(null, t.id)}
@ -116,6 +121,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
🗑 🗑
</button> </button>
</td> </td>
}
<td class={style.mainColumn}> <td class={style.mainColumn}>
<EditableText <EditableText
render={(value) => (<span>{value}</span>)} render={(value) => (<span>{value}</span>)}
@ -166,7 +172,9 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
<tfoot> <tfoot>
<tr> <tr>
<td class={`${style.noBorder} noPrint`}></td> <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"> <div class="field has-addons noPrint">
<p class="control is-expanded"> <p class="control is-expanded">
<input class="input" type="text" placeholder="Nouvelle tâche" <input class="input" type="text" placeholder="Nouvelle tâche"
@ -191,6 +199,7 @@ const TaskTable: FunctionalComponent<TaskTableProps> = ({ project, onTaskAdd, on
</a> </a>
</p> </p>
</div> </div>
}
</td> </td>
<th colSpan={3}>Total</th> <th colSpan={3}>Total</th>
</tr> </tr>

View File

@ -27,6 +27,9 @@ module.exports = {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:8081', 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 go 1.14
require ( require (
github.com/SebastiaanKlippert/go-wkhtmltopdf v1.5.0
github.com/asdine/storm/v3 v3.1.1 github.com/asdine/storm/v3 v3.1.1
github.com/caarlos0/env/v6 v6.2.1 github.com/caarlos0/env/v6 v6.2.1
github.com/davecgh/go-spew v1.1.1 // indirect 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/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.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= 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 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM= 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= 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" "gopkg.in/yaml.v2"
) )
// Config is the configuration struct
type Config struct { type Config struct {
HTTP HTTPConfig `yaml:"http"` HTTP HTTPConfig `yaml:"http"`
Data DataConfig `ymal:"data"` Client ClientConfig `yaml:"client"`
Data DataConfig `ymal:"data"`
} }
// HTTPConfig is the configuration part which defines HTTP related params
type HTTPConfig struct { type HTTPConfig struct {
BaseURL string `yaml:"baseurl" env:"GUESSTIMATE_BASE_URL"`
Address string `yaml:"address" env:"GUESSTIMATE_HTTP_ADDRESS"` Address string `yaml:"address" env:"GUESSTIMATE_HTTP_ADDRESS"`
PublicDir string `yaml:"publicDir" env:"GUESSTIMATE_PUBLIC_DIR"` 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 { type DataConfig struct {
Path string `yaml:"path" env:"GUESSTIMATE_DATA_PATH"` Path string `yaml:"path" env:"GUESSTIMATE_DATA_PATH"`
} }
@ -40,6 +51,7 @@ func NewFromFile(filepath string) (*Config, error) {
return config, nil return config, nil
} }
// WithEnvironment retrieves the configuration from env vars
func WithEnvironment(conf *Config) error { func WithEnvironment(conf *Config) error {
if err := env.Parse(conf); err != nil { if err := env.Parse(conf); err != nil {
return err return err
@ -48,23 +60,31 @@ func WithEnvironment(conf *Config) error {
return nil return nil
} }
// NewDumpDefault retrieves the default configuration
func NewDumpDefault() *Config { func NewDumpDefault() *Config {
config := NewDefault() config := NewDefault()
return config return config
} }
// NewDefault creates and returns a new default configuration
func NewDefault() *Config { func NewDefault() *Config {
return &Config{ return &Config{
HTTP: HTTPConfig{ HTTP: HTTPConfig{
BaseURL: "localhost",
Address: ":8081", Address: ":8081",
PublicDir: "public", PublicDir: "public",
}, },
Client: ClientConfig{
BaseURL: "localhost",
Address: ":8080",
},
Data: DataConfig{ Data: DataConfig{
Path: "guesstimate.db", Path: "guesstimate.db",
}, },
} }
} }
// Dump writes a given config to a config file
func Dump(config *Config, w io.Writer) error { func Dump(config *Config, w io.Writer) error {
data, err := yaml.Marshal(config) data, err := yaml.Marshal(config)
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

@ -2,14 +2,17 @@ package route
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
jsonpatch "gopkg.in/evanphx/json-patch.v4" 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/model"
"forge.cadoles.com/wpetit/guesstimate/internal/storm" "forge.cadoles.com/wpetit/guesstimate/internal/storm"
"github.com/SebastiaanKlippert/go-wkhtmltopdf"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container" "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 { type createRequest struct {
Project *model.Project `json:"project"` Project *model.Project `json:"project"`
} }
@ -246,3 +305,12 @@ func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
return encoder.Encode(data) 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)
}