diff --git a/Project.toml b/Project.toml index ccc9f90..8a8a367 100644 --- a/Project.toml +++ b/Project.toml @@ -4,6 +4,7 @@ authors = ["Chris Parmer ", "Alexandr Romanenko Base64.base64encode(src), + :filename => filename, + :type => type, + :base64 => true + ) +end + +function dcc_send_bytes(writer::Function, data, filename; type = nothing) + io = IOBuffer() + writer(io, data) + return dcc_send_bytes(take!(io), filename, type = type) +end + +""" + dcc_send_data(src::AbstractString, filename; type = nothing) + dcc_send_data(writer::Function, data, filename; type = nothing) + +Convert string into the format expected by the Download component. +`writer` function must have signature `(io::IO, data)` + +# Examples + +Sending string content +```julia +text_data = "this is the test" +callback!(app, Output("download", "data"), Input("download-btn", "n_clicks"), prevent_initial_call = true) do n_clicks + return dcc_send_string(text_data, "text.txt") +end +``` + +Sending `DataFrame` in `CSV` format +```julia +using DataFrames, CSV +... +df = DataFrame(...) +callback!(app, Output("download", "data"), Input("download-btn", "n_clicks"), prevent_initial_call = true) do n_clicks + return dcc_send_string(CSV.write, df, "df.csv") +end +``` +""" +function dcc_send_string(src::AbstractString, filename; type = nothing) + + return Dict( + :content => src, + :filename => filename, + :type => type, + :base64 => false + ) +end + +function dcc_send_string(writer::Function, data, filename; type = nothing) + io = IOBuffer() + writer(io, data) + return dcc_send_string(String(take!(io)), filename, type = type) +end \ No newline at end of file diff --git a/src/components_utils/table_format.jl b/src/components_utils/table_format.jl new file mode 100644 index 0000000..b2af952 --- /dev/null +++ b/src/components_utils/table_format.jl @@ -0,0 +1,336 @@ +module TableFormat + using JSON3 + export Format, Align, Group, Padding, Prefix, Scheme, Sign, DSymbol, Trim + + struct NamedValue{Name, T} + value::T + NamedValue{Name}(value::T) where {Name, Keys, T} = new{Name, T}(value) + end + + struct TupleWithNamedValues{Name, Keys} + values::NamedTuple + TupleWithNamedValues{Name, Keys}(values) where {Name, Keys} = new{Name, Keys}(values) + end + + Base.getproperty(tp::TupleWithNamedValues, prop::Symbol) = getfield(tp, :values)[prop] + + function tuple_with_named_values(name::Symbol, t::NamedTuple{Names}) where {Names} + return TupleWithNamedValues{name, Names}((;zip(Names, NamedValue{name}.(values(t)))...)) + end + + function possible_values(::TupleWithNamedValues{Name, Keys}) where {Name, Keys} + return join(string.(string(Name), ".", string.(Keys)), ", ") + end + + const Align = tuple_with_named_values(:Align, ( + default = "", + left = "<", + right = ">", + center = "^", + right_sign = "=" + )) + + const Group = tuple_with_named_values(:Group, ( + no = "", + yes = "," + )) + + const Padding = tuple_with_named_values(:Padding, (no = "", yes = 0)) + + const Prefix = tuple_with_named_values(:Prefix, ( + yocto = 10 ^ -24, + zepto = 10 ^ -21, + atto = 10 ^ -18, + femto = 10 ^ -15, + pico = 10 ^ -12, + nano = 10 ^ -9, + micro = 10 ^ -6, + milli = 10 ^ -3, + none = nothing, + kilo = 10 ^ 3, + mega = 10 ^ 6, + giga = 10 ^ 9, + tera = 10 ^ 12, + peta = 10 ^ 15, + exa = 10 ^ 18, + zetta = 10 ^ 21, + yotta = 10 ^ 24 + )) + const Scheme = tuple_with_named_values(:Scheme, ( + default = "", + decimal = "r", + decimal_integer = "d", + decimal_or_exponent = "g", + decimal_si_prefix = "s", + exponent = "e", + fixed = "f", + percentage = "%", + percentage_rounded = "p", + binary = "b", + octal = "o", + lower_case_hex = "x", + upper_case_hex = "X", + unicode = "c", + )) + const Sign = tuple_with_named_values(:Sign, ( + default = "", + negative = "-", + positive = "+", + parantheses = "(", + space = " " + )) + const DSymbol = tuple_with_named_values(:DSymbol, ( + no = "", + yes = "\$", + binary = "#b", + octal = "#o", + hex = "#x" + )) + const Trim = tuple_with_named_values(:Trim, ( + no = "", + yes = "~" + )) + + mutable struct Format + locale + nully + prefix + specifier + function Format(; + align = Align.default, + fill = nothing, + group = Group.no, + padding = Padding.no, + padding_width = nothing, + precision = nothing, + scheme = Scheme.default, + sign = Sign.default, + symbol = DSymbol.no, + trim = Trim.no, + + symbol_prefix = nothing, + symbol_suffix = nothing, + decimal_delimiter = nothing, + group_delimiter = nothing, + groups = nothing, + + nully = "", + + si_prefix = Prefix.none + ) + result = new( + Dict(), + "", + Prefix.none.value, + Dict{Symbol, Any}() + ) + align!(result, align) + fill!(result, fill) + group!(result, group) + padding!(result, padding) + padding_width!(result, padding_width) + precision!(result, precision) + scheme!(result, scheme) + sign!(result, sign) + symbol!(result, symbol) + trim!(result, trim) + + !isnothing(symbol_prefix) && symbol_prefix!(result, symbol_prefix) + !isnothing(symbol_suffix) && symbol_suffix!(result, symbol_suffix) + !isnothing(decimal_delimiter) && decimal_delimiter!(result, decimal_delimiter) + !isnothing(group_delimiter) && group_delimiter!(result, group_delimiter) + !isnothing(groups) && groups!(result, groups) + !isnothing(nully) && nully!(result, nully) + !isnothing(si_prefix) && si_prefix!(result, si_prefix) + + return result + end + end + + function check_named_value(t::TupleWithNamedValues{Name, Keys}, v::NamedValue{ValueName}) where {Name, ValueName, Keys} + Name != ValueName && throw(ArgumentError("expected value to be one of $(possible_values(t))")) + return true + end + function check_named_value(t::TupleWithNamedValues{Name, Keys}, v) where {Name, Keys} + throw(ArgumentError("expected value to be one of $(possible_values(t))")) + end + + check_char_value(v::Char) = string(v) + function check_char_value(v::String) + length(v) > 1 && throw(ArgumentError("expected char or string of length one")) + return v + end + function check_char_value(v) + throw(ArgumentError("expected char or string of length one")) + end + + function check_int_value(v::Integer) + v < 0 && throw(ArgumentError("expected value to be non-negative")) + end + + function check_int_value(v) + throw(ArgumentError("expected value to be Integer")) + end + + function align!(f::Format, value) + check_named_value(Align, value) + f.specifier[:align] = value.value + end + + function fill!(f::Format, value) + if isnothing(value) + f.specifier[:fill] = "" + return + end + v = check_char_value(value) + f.specifier[:fill] = v + end + + + function group!(f::Format, value) + if value isa Bool + value = value ? Group.yes : Group.no + end + check_named_value(Group, value) + f.specifier[:group] = value.value + end + + function padding!(f::Format, value) + if value isa Bool + value = value ? Padding.yes : Padding.no + end + check_named_value(Padding, value) + f.specifier[:padding] = value.value + end + + function padding_width!(f::Format, value) + if isnothing(value) + f.specifier[:width] = "" + else + check_int_value(value) + f.specifier[:width] = value + end + end + + function precision!(f::Format, value) + if isnothing(value) + f.specifier[:precision] = "" + else + check_int_value(value) + f.specifier[:precision] = ".$value" + end + end + + function scheme!(f::Format, value) + check_named_value(Scheme, value) + f.specifier[:type] = value.value + end + + function sign!(f::Format, value) + check_named_value(Sign, value) + f.specifier[:sign] = value.value + end + + function symbol!(f::Format, value) + check_named_value(DSymbol, value) + f.specifier[:symbol] = value.value + end + + function trim!(f::Format, value) + if value isa Bool + value = value ? Trim.yes : Trim.no + end + check_named_value(Trim, value) + f.specifier[:trim] = value.value + end + + # Locale + function symbol_prefix!(f::Format, value::AbstractString) + if !haskey(f.locale, :symbol) + f.locale[:symbol] = [value, ""] + else + f.locale[:symbol][1] = value + end + end + + function symbol_suffix!(f::Format, value::AbstractString) + if !haskey(f.locale, :symbol) + f.locale[:symbol] = ["", value] + else + f.locale[:symbol][2] = value + end + end + + function decimal_delimiter!(f::Format, value) + v = check_char_value(value) + f.locale[:decimal] = v + end + + function group_delimiter!(f::Format, value) + v = check_char_value(value) + f.locale[:group] = v + end + + function groups!(f::Format, value::Union{Vector{<:Integer}, <:Integer}) + groups = value isa Integer ? [value] : value + isempty(groups) && throw(ArgumentError("groups cannot be empty")) + + for g in groups + g < 0 && throw(ArgumentError("group entry must be non-negative integer")) + end + f.locale[:grouping] = groups + end + + # Nully + function nully!(f::Format, value) + f.nully = value + end + + # Prefix + function si_prefix!(f::Format, value) + check_named_value(Prefix, value) + f.prefix = value.value + end + + JSON3.StructTypes.StructType(::Type{Format}) = JSON3.RawType() + + function JSON3.rawbytes(f::Format) + aligned = f.specifier[:align] != Align.default.value + fill = aligned ? f.specifier[:fill] : "" + spec_io = IOBuffer() + print(spec_io, + aligned ? f.specifier[:fill] : "", + f.specifier[:align], + f.specifier[:sign], + f.specifier[:symbol], + f.specifier[:padding], + f.specifier[:width], + f.specifier[:group], + f.specifier[:precision], + f.specifier[:trim], + f.specifier[:type] + ) + return JSON3.write( + ( + locale = f.locale, + nully = f.nully, + prefix = f.prefix, + specifier = String(take!(spec_io)) + ) + ) + end + + money(decimals, sign = Sign.default) = Format( + group=Group.yes, + precision=decimals, + scheme=Scheme.fixed, + sign=sign, + symbol=Symbol.yes + ) + + + function percentage(decimals, rounded::Bool=false) + scheme = rounded ? Scheme.percentage_rounded : Scheme.percentage + return Format(scheme = scheme, precision = decimals) + end +end \ No newline at end of file diff --git a/src/handler/index_page.jl b/src/handler/index_page.jl index 039df6e..6e6620e 100644 --- a/src/handler/index_page.jl +++ b/src/handler/index_page.jl @@ -103,7 +103,7 @@ function favicon_html(app::DashApp, resources::ApplicationResources) favicon_url = if !isnothing(resources.favicon) asset_path(app, resources.favicon.path) else - "$(get_setting(app, :requests_pathname_prefix))_favicon.ico?v=$(build_info().dash_version)" + "$(get_setting(app, :requests_pathname_prefix))_favicon.ico?v=$(_metadata.dash["version"])" end return format_tag( "link", diff --git a/test/components_utils.jl b/test/components_utils.jl new file mode 100644 index 0000000..d908138 --- /dev/null +++ b/test/components_utils.jl @@ -0,0 +1,51 @@ +using Test +using Dash +using Base64 +@testset "Send String" begin + data = "test string" + res = dcc_send_string(data, "test_file.csv"; type = "text/csv") + @test res[:content] == data + @test res[:filename] == "test_file.csv" + @test res[:type] == "text/csv" + @test !res[:base64] + + data2 = "test 2 string" + res = dcc_send_string(data2, "test_file.csv"; type = "text/csv") do io, data + write(io, data) + end + @test res[:content] == data2 + @test res[:filename] == "test_file.csv" + @test res[:type] == "text/csv" + @test !res[:base64] +end +@testset "Send Bytes" begin + data = "test string" + res = dcc_send_bytes(Vector{UInt8}(data), "test_file.csv"; type = "text/csv") + @test res[:content] == Base64.base64encode(data) + @test res[:filename] == "test_file.csv" + @test res[:type] == "text/csv" + @test res[:base64] + + data2 = "test 2 string" + res = dcc_send_bytes(write, data2, "test_file.csv"; type = "text/csv") + @test res[:content] == Base64.base64encode(data2) + @test res[:filename] == "test_file.csv" + @test res[:type] == "text/csv" + @test res[:base64] +end +@testset "Send File" begin + + file = "assets/test.png" + res = dcc_send_file(file, nothing; type = "text/csv") + @test res[:content] == Base64.base64encode(read(file)) + @test res[:filename] == "test.png" + @test res[:type] == "text/csv" + @test res[:base64] + + file = "assets/test.png" + res = dcc_send_file(file, "ttt.jpeg"; type = "text/csv") + @test res[:content] == Base64.base64encode(read(file)) + @test res[:filename] == "ttt.jpeg" + @test res[:type] == "text/csv" + @test res[:base64] +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index 9617715..9dde565 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -9,4 +9,6 @@ include("context.jl") include("core.jl") include("misc.jl") include("callbacks.jl") -#include("dev.jl") +include("components_utils.jl") +include("table_format.jl") +#include("dev.jl") \ No newline at end of file diff --git a/test/table_format.jl b/test/table_format.jl new file mode 100644 index 0000000..252ae48 --- /dev/null +++ b/test/table_format.jl @@ -0,0 +1,177 @@ +using Test +using Dash +using Dash.TableFormat +import JSON3 +@testset "named values" begin + @test TableFormat.Align.left.value == "<" + a = TableFormat.Align.default + @test a == TableFormat.Align.default + @test TableFormat.possible_values(TableFormat.Align) == "Align.default, Align.left, Align.right, Align.center, Align.right_sign" + @test a != TableFormat.Align.left + @test a != TableFormat.Scheme.default +end + +@testset "specifier" begin + f = Format() + + @test f.specifier[:align] == TableFormat.Align.default.value + TableFormat.align!(f, TableFormat.Align.left) + @test f.specifier[:align] == TableFormat.Align.left.value + @test_throws ArgumentError TableFormat.align!(f, TableFormat.Group.no) + + TableFormat.fill!(f, '-') + @test f.specifier[:fill] == "-" + TableFormat.fill!(f, "+") + @test f.specifier[:fill] == "+" + @test_throws ArgumentError TableFormat.fill!(f, "++") + + + TableFormat.group!(f, TableFormat.Group.yes) + @test f.specifier[:group] == TableFormat.Group.yes.value + + TableFormat.group!(f, true) + @test f.specifier[:group] == TableFormat.Group.yes.value + TableFormat.group!(f, false) + @test f.specifier[:group] == TableFormat.Group.no.value + @test_throws ArgumentError TableFormat.group!(f, "ffff") + + TableFormat.padding!(f, TableFormat.Padding.yes) + @test f.specifier[:padding] == TableFormat.Padding.yes.value + TableFormat.padding!(f, true) + @test f.specifier[:padding] == TableFormat.Padding.yes.value + TableFormat.padding!(f, false) + @test f.specifier[:padding] == TableFormat.Padding.no.value + @test_throws ArgumentError TableFormat.padding!(f, "ffff") + + TableFormat.padding_width!(f, 100) + @test f.specifier[:width] == 100 + TableFormat.padding_width!(f, nothing) + @test f.specifier[:width] == "" + @test_throws ArgumentError TableFormat.padding_width!(f, "ffff") + @test_throws ArgumentError TableFormat.padding_width!(f, -10) + + TableFormat.precision!(f, 3) + @test f.specifier[:precision] == ".3" + TableFormat.precision!(f, nothing) + @test f.specifier[:precision] == "" + @test_throws ArgumentError TableFormat.precision!(f, "ffff") + @test_throws ArgumentError TableFormat.precision!(f, -10) + + @test f.specifier[:type] == TableFormat.Scheme.default.value + TableFormat.scheme!(f, TableFormat.Scheme.decimal) + @test f.specifier[:type] == TableFormat.Scheme.decimal.value + @test_throws ArgumentError TableFormat.scheme!(f, "ccc") + + @test f.specifier[:sign] == TableFormat.Sign.default.value + TableFormat.sign!(f, TableFormat.Sign.negative) + @test f.specifier[:sign] == TableFormat.Sign.negative.value + @test_throws ArgumentError TableFormat.sign!(f, "ccc") + + @test f.specifier[:symbol] == TableFormat.DSymbol.no.value + TableFormat.symbol!(f, TableFormat.DSymbol.yes) + @test f.specifier[:symbol] == TableFormat.DSymbol.yes.value + @test_throws ArgumentError TableFormat.symbol!(f, "ccc") + + @test f.specifier[:trim] == TableFormat.Trim.no.value + TableFormat.trim!(f, TableFormat.Trim.yes) + @test f.specifier[:trim] == TableFormat.Trim.yes.value + TableFormat.trim!(f, false) + @test f.specifier[:trim] == TableFormat.Trim.no.value + @test_throws ArgumentError TableFormat.trim!(f, "ccc") + +end + +@testset "locale" begin + f = Format() + @test isempty(f.locale) + TableFormat.symbol_prefix!(f, "lll") + @test haskey(f.locale, :symbol) + @test f.locale[:symbol][1] == "lll" + @test f.locale[:symbol][2] == "" + TableFormat.symbol_prefix!(f, "rrr") + @test f.locale[:symbol][1] == "rrr" + @test f.locale[:symbol][2] == "" + + + f = Format() + @test isempty(f.locale) + TableFormat.symbol_suffix!(f, "lll") + @test haskey(f.locale, :symbol) + @test f.locale[:symbol][1] == "" + @test f.locale[:symbol][2] == "lll" + TableFormat.symbol_suffix!(f, "rrr") + @test f.locale[:symbol][1] == "" + @test f.locale[:symbol][2] == "rrr" + + TableFormat.symbol_prefix!(f, "kkk") + @test f.locale[:symbol][1] == "kkk" + @test f.locale[:symbol][2] == "rrr" + + f = Format() + TableFormat.decimal_delimiter!(f, '|') + @test f.locale[:decimal] == "|" + TableFormat.group_delimiter!(f, ';') + @test f.locale[:group] == ";" + + TableFormat.groups!(f, [3,3,3]) + @test f.locale[:grouping] == [3,3,3] + + TableFormat.groups!(f, [5]) + @test f.locale[:grouping] == [5] + + @test_throws ArgumentError TableFormat.groups!(f, Int[]) + @test_throws ArgumentError TableFormat.groups!(f, Int[-1,2]) +end + +@testset "nully & prefix" begin + f = Format() + TableFormat.nully!(f, "gggg") + @test f.nully == "gggg" + TableFormat.si_prefix!(f, TableFormat.Prefix.femto) + @test f.prefix == TableFormat.Prefix.femto.value +end + +@testset "Format creation" begin + f = Format( + align = Align.left, + fill = "+", + group = true, + padding = Padding.yes, + padding_width = 10, + precision = 2, + scheme = Scheme.decimal, + sign = Sign.negative, + symbol = DSymbol.yes, + trim = Trim.yes, + + symbol_prefix = "tt", + symbol_suffix = "kk", + decimal_delimiter = ";", + group_delimiter = ",", + groups = [2, 2], + nully = "f", + si_prefix = Prefix.femto + ) + + @test f.specifier == Dict{Symbol, Any}( + :align => Align.left.value, + :fill => "+", + :group => Group.yes.value, + :padding => Padding.yes.value, + :width => 10, + :precision => ".2", + :type => Scheme.decimal.value, + :sign => Sign.negative.value, + :symbol => DSymbol.yes.value, + :trim => Trim.yes.value + ) + + @test f.nully == "f" + @test f.prefix == Prefix.femto.value + + @test f.locale[:symbol] == ["tt", "kk"] + @test f.locale[:decimal] == ";" + @test f.locale[:group] == "," + @test f.locale[:grouping] == [2,2] + +end \ No newline at end of file