diff --git a/README.md b/README.md index 6a86dd3..6b6c479 100644 --- a/README.md +++ b/README.md @@ -6,32 +6,28 @@ This repository contains the requirements definition of a project used for job a for a **position of an [Elm](https://elm-lang.org/) developer at Scrive**. The whole task is based on a real part of our production application and real needs we have addressed. -It also contains the skeleton of an Elm application with technologies we are currently +It also contains the skeleton of an Elm application with technologies we are currently using - [Tailwind CSS](https://tailwindcss.com) and [Vite](https://vitejs.dev). -## Task parts - -You must deliver at least one part of your choice. (You can of course do more parts). - -You can choose from following parts: - -- [Settings](task-settings.md) -- [Contact details](task-contact-details.md) -- [Tags](task-tags.md) +## Task +You are asked to create UI for [Tags](task-tags.md) ## Goal The aim is to test your ability to come up with a solution to a real-world problem that will be part of your day-to-day responsibilities. Obviously the first thing what we will look at is the extent to which your implementation meets the original requirements. -We also want to see your ability to come up with a robust solution and will look at the overall code quality. +We also want to see your ability to come up with a robust solution and will look at the overall code quality and choices. ## Where to Start If you are interested in applying for an Elm developer position or just want to challenge yourself (which is also 100% OK with us), please follow the steps below: -- Pick at least one [task](#task-parts) and read through the requirements. +If you are already in the process of application, you should have access to Scrive CAT - follow instructions in Scrive CAT app. + +Otherwise: + - Fork this repository under your GitHub account. - Complete an implementation within your fork. - Open a pull request to the [original](https://github.com/scrive/elm-challenge/) repository with your own implementation. @@ -44,8 +40,8 @@ Good luck and enjoy the challenge. ## How to start the dev server - ``` npm ci npm start ``` +test stuff diff --git a/elm.json b/elm.json index 00529a5..9059e06 100644 --- a/elm.json +++ b/elm.json @@ -6,10 +6,13 @@ "elm-version": "0.19.1", "dependencies": { "direct": { + "NoRedInk/elm-json-decode-pipeline": "1.0.1", "elm/browser": "1.0.2", "elm/core": "1.0.5", "elm/html": "1.0.0", - "elm/json": "1.1.3" + "elm/json": "1.1.3", + "elm/svg": "1.0.1", + "elm-community/html-extra": "3.4.0" }, "indirect": { "elm/time": "1.0.0", diff --git a/index.html b/index.html index 58b82c5..32b777e 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ Elm challenge +
diff --git a/package-lock.json b/package-lock.json index 6b49fa5..5866c2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "devDependencies": { "autoprefixer": "^10.4.15", "elm-tooling": "^1.14.0", - "postcss": "^8.4.29", + "postcss": "^8.4.31", "tailwindcss": "^3.3.3", "vite": "^4.4.9", "vite-plugin-elm": "^2.8.0" @@ -1375,9 +1375,9 @@ } }, "node_modules/postcss": { - "version": "8.4.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", - "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 7718b49..523d5f6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "devDependencies": { "autoprefixer": "^10.4.15", "elm-tooling": "^1.14.0", - "postcss": "^8.4.29", + "postcss": "^8.4.31", "tailwindcss": "^3.3.3", "vite": "^4.4.9", "vite-plugin-elm": "^2.8.0" diff --git a/src/Main.elm b/src/Main.elm index fd56972..9fd88e4 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -4,76 +4,400 @@ import Browser import Data import Html exposing (Html) import Html.Attributes as Attrs +import Html.Attributes.Extra as AttrsExtra +import Html.Events as Events +import Html.Extra +import Json.Decode as Decode +import Json.Decode.Pipeline as Pipeline +import Shared.Button as Button +import Shared.Editable as Editable +import Shared.Icon as Icon +import Shared.Value as Value - ----- MODEL ---- +type alias Tag = + { name : Editable.Editable String + , value : Editable.Editable String + } type alias Model = - {} + { tags : List Tag + , newTagForm : Tag + , globalError : Maybe String + } init : ( Model, Cmd Msg ) init = - ( {}, Cmd.none ) + let + ( tags, error ) = + decodeTagsWithMaybeError + in + ( { tags = tags, newTagForm = emptyNewTagForm, globalError = error }, Cmd.none ) + + +emptyNewTagForm : Tag +emptyNewTagForm = + { name = Value.validValue "" |> Editable.valueToEditable + , value = Value.validValue "" |> Editable.valueToEditable + } + + +tagDecoder : Decode.Decoder Tag +tagDecoder = + Decode.succeed Tag + |> Pipeline.required "name" (Decode.map Editable.toReadonly Decode.string) + |> Pipeline.optional "value" (Decode.map Editable.toReadonly Decode.string) (Editable.toReadonly "") +decodeTagsWithMaybeError : ( List Tag, Maybe String ) +decodeTagsWithMaybeError = + let + decodeTags = + Decode.field "tags" (Decode.list tagDecoder) + in + case Decode.decodeString decodeTags Data.userGroup of + Ok decodedTags -> + ( decodedTags, Nothing ) ----- UPDATE ---- + Err decodeError -> + ( [], Just ("Error decoding data: " ++ Decode.errorToString decodeError) ) type Msg - = NoOp + = UpdateNewName String + | UpdateNewValue String + | ClickedAddTag + | ClickedRemoveTag String + | InsertedTagValue String String + | ClickedEditTagValue Bool String + | ClickedReloadData + | NoOp update : Msg -> Model -> ( Model, Cmd Msg ) -update _ model = - ( model, Cmd.none ) +update msg model = + case msg of + UpdateNewName nameString -> + let + updateName tag = + { tag | name = Editable.updateIfEditable nameString tag.name } + in + ( { model | newTagForm = updateName model.newTagForm }, Cmd.none ) + + UpdateNewValue valueString -> + let + updateValue tag = + { tag | value = Editable.updateIfEditable valueString tag.value } + in + ( { model | newTagForm = updateValue model.newTagForm }, Cmd.none ) + + ClickedAddTag -> + let + validatedName = + Editable.toValue model.newTagForm.name |> validateNameValue model.tags + in + if Value.isValid validatedName then + ( { model + | tags = + model.tags + ++ [ { name = Value.fromValue validatedName |> Editable.toReadonly + , value = model.newTagForm.value |> Editable.setReadonly + } + ] + , newTagForm = emptyNewTagForm + } + , Cmd.none + ) + + else + ( { model + | newTagForm = + { name = Editable.valueToEditable validatedName + , value = model.newTagForm.value + } + } + , Cmd.none + ) + + ClickedRemoveTag nameToRemove -> + let + updatedTags = + List.filter (.name >> Editable.toValue >> Value.fromValue >> (/=) nameToRemove) model.tags + + validatedName = + Editable.toValue model.newTagForm.name |> validateNameValue updatedTags + + updateName tag = + { tag | name = Editable.valueToEditable validatedName } + in + ( { model + | tags = updatedTags + , newTagForm = updateName model.newTagForm + } + , Cmd.none + ) + + InsertedTagValue nameString newValue -> + let + updatedTags = + List.map + (\tag -> + if (Editable.toValue tag.name |> Value.fromValue) == nameString then + { tag | value = Editable.updateIfEditable newValue tag.value } + + else + tag + ) + model.tags + in + ( { model | tags = updatedTags }, Cmd.none ) + + ClickedEditTagValue isEditable tagName -> + let + updatedTags = + List.map + (\tag -> + if (Editable.toValue tag.name |> Value.fromValue) == tagName then + { tag + | value = + if isEditable then + Editable.setEditable tag.value + + else + Editable.setReadonly tag.value + } + + else + tag + ) + model.tags + in + ( { model | tags = updatedTags }, Cmd.none ) + + ClickedReloadData -> + let + ( tags, error ) = + decodeTagsWithMaybeError + in + ( { tags = tags, newTagForm = emptyNewTagForm, globalError = error }, Cmd.none ) + + NoOp -> + ( model, Cmd.none ) +validateNameValue : List Tag -> Value.Value String -> Value.Value String +validateNameValue tags nameValue = + let + nameString = + Value.fromValue nameValue + in + if String.isEmpty nameString then + Value.addError "Name is required" nameValue ----- VIEW ---- + else if List.any (.name >> Editable.toValue >> Value.fromValue >> (==) nameString) tags then + Value.addError "Duplicate name" nameValue + + else + Value.validValue nameString header : String -> Html msg header text = - Html.span [ Attrs.class "p-2 text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-slate-400 to-slate-800" ] + Html.span [ Attrs.class "p-2 text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-[#0052CC] to-[#003366]" ] [ Html.text text ] subheader : String -> Html msg subheader text = - Html.span [ Attrs.class "p-2 text-2xl font-extrabold text-slate-800" ] + Html.span [ Attrs.class "p-2 text-2xl font-extrabold text-[#1A1A1A]" ] [ Html.text text ] view : Model -> Html Msg -view _ = - Html.div [ Attrs.class "flex flex-col w-[1024px] items-center mx-auto mt-16 mb-48" ] - [ header "Let's start your task" - , subheader "Here are your data:" - , Html.pre [ Attrs.class "my-8 py-4 px-12 text-sm bg-slate-100 font-mono shadow rounded" ] [ Html.text Data.userGroup ] - , header "Now turn them into form." - , subheader "See README for details of the task. Good luck 🍀 " +view model = + let + newTagFormView = + Html.form + [ Attrs.class "mb-6 grid grid-cols-1 md:grid-cols-[2fr_2fr_auto] gap-3 items-center w-full max-w-full" + , Events.preventDefaultOn "submit" (Decode.succeed ( ClickedAddTag, True )) + ] + [ inputField "Tag Name (required)" model.newTagForm.name UpdateNewName + , inputField "Tag Value" model.newTagForm.value UpdateNewValue + , Button.new (Button.OnSubmit ClickedAddTag) + |> Button.withClass "bg-blue-600 text-white rounded-full shadow hover:bg-blue-700 transition w-10 h-10 flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-blue-400" + |> Button.withIcon Icon.Add + |> Button.withTooltip "Add new tag" + |> Button.view + ] + in + Html.div [ Attrs.class "min-h-screen bg-gray-50 py-16 px-6 flex flex-col items-center w-full max-w-full" ] + (([ header "Tags form" + , subheader "Form for tags management" + ] + ++ globalErrorView model.globalError + ) + ++ [ Html.div + [ Attrs.class "w-full max-w-lg bg-white p-6 rounded-xl shadow-md border border-gray-200 mt-8" ] + [ refreshButtonView + , newTagFormView + , [ List.map tagView model.tags + |> Html.div [ Attrs.class "space-y-3", Attrs.attribute "role" "list", Attrs.attribute "aria-label" "Tags list" ] + ] + |> Html.div [ Attrs.class "max-h-[60vh] overflow-y-auto p-5" ] + ] + ] + ) + + +globalErrorView : Maybe String -> List (Html msg) +globalErrorView = + Maybe.map + (Html.text + >> List.singleton + >> Html.div + [ Attrs.class "text-red-600 mb-4 font-medium" + , Attrs.attribute "role" "alert" + , Attrs.attribute "aria-live" "assertive" + ] + >> List.singleton + ) + >> Maybe.withDefault [] + + +refreshButtonView : Html Msg +refreshButtonView = + Html.div [ Attrs.class "flex justify-end py-4" ] + [ Button.new (Button.OnClick ClickedReloadData) + |> Button.withClass "ml-4 p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition" + |> Button.withIcon Icon.Refresh + |> Button.withTooltip "Reload data" + |> Button.view ] +tagView : Tag -> Html Msg +tagView tag = + let + valueValue = + Editable.toValue tag.value |> Value.fromValue + + nameValue = + Editable.toValue tag.name |> Value.fromValue + in + Html.div + [ Attrs.class "flex items-start gap-3 bg-white p-3 rounded-lg border border-gray-200 shadow-sm min-h-[52px] box-border" + , Attrs.attribute "role" "listitem" + ] + [ Html.div + [ Attrs.class "flex-1 px-3 py-1 flex items-start text-gray-800 whitespace-pre-wrap break-all border border-transparent box-border" + , Attrs.attribute "aria-label" ("Name for tag " ++ nameValue) + ] + [ Html.text nameValue ] + , Html.div [ Attrs.class "flex-1 flex items-start gap-2 box-border" ] + (if Editable.isEditable tag.value then + [ Html.input + [ Attrs.value valueValue + , Events.onInput (InsertedTagValue nameValue) + , Attrs.class "border border-gray-300 rounded-md px-3 w-full h-9 focus:ring-blue-400 focus:border-blue-400 box-border" + , Attrs.attribute "aria-label" ("Value for tag " ++ nameValue) + ] + [] + , Html.div [ Attrs.class "relative flex items-start h-9 box-border" ] + [ Button.new (ClickedEditTagValue False nameValue |> Button.OnClick) + |> Button.withClass "w-8 h-8 flex items-center justify-center bg-gray-400 text-white rounded-md hover:bg-green-600 transition" + |> Button.withIcon Icon.Checkmark + |> Button.withTooltip "Confirm edit for tag" + |> Button.view + ] + ] + + else + [ Html.div + [ Attrs.class "w-full px-3 py-1 flex items-center text-gray-800 whitespace-pre-wrap break-all border border-transparent box-border" + , Attrs.attribute "aria-label" ("Value for tag " ++ nameValue) + ] + [ Html.text valueValue ] + , Html.div [ Attrs.class "relative flex items-start h-9 box-border" ] + [ Button.new (ClickedEditTagValue True nameValue |> Button.OnClick) + |> Button.withClass "w-8 h-8 flex items-center justify-center bg-gray-400 text-white rounded-md hover:bg-yellow-500 transition" + |> Button.withIcon Icon.Edit + |> Button.withTooltip "Edit tag value" + |> Button.view + ] + ] + ) + , Html.div [ Attrs.class "relative flex items-start h-9 box-border" ] + [ Button.new (ClickedRemoveTag nameValue |> Button.OnClick) + |> Button.withClass "w-8 h-8 flex items-center justify-center bg-gray-400 text-white rounded-md hover:bg-red-600 transition" + |> Button.withIcon Icon.Remove + |> Button.withTooltip "Remove tag" + |> Button.view + ] + ] ----- PROGRAM ---- + +inputField : String -> Editable.Editable String -> (String -> Msg) -> Html Msg +inputField labelText editable toMsg = + let + valueString = + Editable.toValue editable |> Value.fromValue + + errorText = + Editable.toValue editable |> Value.getMaybeError + + borderClass = + case errorText of + Just _ -> + "border-red-500 focus:ring-red-400 focus:border-red-400" + + Nothing -> + "border-gray-300 focus:ring-blue-400 focus:border-blue-400" + + wrapperClass = + "relative w-full" + + controlBoxClass = + "block w-full h-11 rounded-md border px-3 text-sm text-gray-900 placeholder-gray-400 transition " ++ borderClass + + inputClass = + "w-full h-full bg-transparent text-sm text-gray-900 focus:outline-none" + in + Html.div [ Attrs.class "w-full" ] + [ Html.label + [ Attrs.class "block text-gray-700 text-sm font-medium mb-1" + , Attrs.attribute "for" labelText + ] + [ Html.text labelText ] + , Html.div [ Attrs.class wrapperClass ] + [ Html.div [ Attrs.class controlBoxClass ] + [ Html.input + [ Attrs.id labelText + , Attrs.value valueString + , Events.onInput toMsg + , Attrs.class inputClass + , Attrs.maxlength 32 + , Attrs.attribute "readonly" "true" |> AttrsExtra.attributeIf (not <| Editable.isEditable editable) + ] + [] + , Html.div + [ Attrs.class "pointer-events-none absolute inset-0 flex items-center px-3 text-gray-700" ] + [ Html.text valueString ] + |> Html.Extra.viewIf (Editable.isEditable editable |> not) + ] + ] + , Html.p + [ Attrs.class "text-red-600 text-xs mt-1 h-4" ] + [ Html.text (Maybe.withDefault "" errorText) ] + ] main : Program () Model Msg main = - Browser.application - { view = - \model -> - { title = "Scrive elm challenge task" - , body = [ view model ] - } - , init = \_ _ _ -> init + Browser.element + { init = \_ -> init + , view = view , update = update - , subscriptions = always Sub.none - , onUrlRequest = always NoOp - , onUrlChange = always NoOp + , subscriptions = \_ -> Sub.none } diff --git a/src/Shared/Button.elm b/src/Shared/Button.elm new file mode 100644 index 0000000..5550af2 --- /dev/null +++ b/src/Shared/Button.elm @@ -0,0 +1,98 @@ +module Shared.Button exposing (Button, ButtonMsg(..), new, view, withClass, withIcon, withLabel, withTooltip) + +import Html exposing (Html) +import Html.Attributes as Attrs +import Html.Events as Events +import Shared.Icon as Icon +import Shared.Tooltip as Tooltip + + +type alias Button msg = + { msg : ButtonMsg msg + , icon : Maybe Icon.IconType + , tooltipText : Maybe String + , class : String + , label : Maybe String + } + + +type ButtonMsg msg + = OnClick msg + | OnSubmit msg + + +new : ButtonMsg msg -> Button msg +new msg = + { msg = msg + , icon = Nothing + , tooltipText = Nothing + , class = "" + , label = Nothing + } + + +withIcon : Icon.IconType -> Button msg -> Button msg +withIcon iconType button = + { button | icon = Just iconType } + + +withTooltip : String -> Button msg -> Button msg +withTooltip text button = + { button | tooltipText = Just text } + + +withClass : String -> Button msg -> Button msg +withClass class button = + { button | class = class } + + +withLabel : String -> Button msg -> Button msg +withLabel text button = + { button | label = Just text } + + +view : Button msg -> Html msg +view { msg, tooltipText, icon, label, class } = + let + ( tooltipId, tooltipAttributes ) = + let + tooltipId_ = + "tooltip-" ++ Maybe.withDefault "button" label + in + Maybe.map (( tooltipId_, [ Attrs.attribute "aria-describedby" tooltipId_ ] ) |> always) tooltipText + |> Maybe.withDefault ( "", [] ) + + content = + case ( icon, label ) of + ( Just icon_, Just label_ ) -> + [ Icon.view icon_, Html.text label_ ] + + ( Just icon_, Nothing ) -> + [ Icon.view icon_ ] + + ( Nothing, Just label_ ) -> + [ Html.text label_ ] + + ( Nothing, Nothing ) -> + [] + + ( clickAttrs, typeAttr ) = + case msg of + OnClick msg_ -> + ( [ Events.onClick msg_ ], Attrs.type_ "button" ) + + OnSubmit _ -> + ( [], Attrs.type_ "submit" ) + in + (Html.button + ([ typeAttr + , Attrs.class class + , Attrs.attribute "aria-label" (Maybe.withDefault "Button" label) + ] + ++ tooltipAttributes + ++ clickAttrs + ) + content + :: (Maybe.map (\text -> [ Tooltip.view { id = tooltipId, text = text } ]) tooltipText |> Maybe.withDefault []) + ) + |> Html.div [ Attrs.class "relative flex items-center group" ] diff --git a/src/Shared/Editable.elm b/src/Shared/Editable.elm new file mode 100644 index 0000000..61f9922 --- /dev/null +++ b/src/Shared/Editable.elm @@ -0,0 +1,68 @@ +module Shared.Editable exposing (Editable, isEditable, setEditable, setReadonly, toReadonly, toValue, updateIfEditable, valueToEditable) + +import Shared.Value as Value exposing (Value) + + +type Editable a + = Editable (Value a) + | Readonly a + + +valueToEditable : Value a -> Editable a +valueToEditable = + Editable + + +toReadonly : a -> Editable a +toReadonly = + Readonly + + +setEditable : Editable a -> Editable a +setEditable editable = + case editable of + Readonly value -> + Editable <| Value.validValue value + + Editable _ -> + editable + + +setReadonly : Editable a -> Editable a +setReadonly editable = + case editable of + Editable value -> + Readonly <| Value.fromValue value + + Readonly _ -> + editable + + +toValue : Editable a -> Value a +toValue editable = + case editable of + Editable value -> + value + + Readonly string -> + Value.validValue string + + +updateIfEditable : a -> Editable a -> Editable a +updateIfEditable newValue editable = + case editable of + Editable _ -> + Value.validValue newValue |> Editable + + Readonly value -> + Readonly value + + +isEditable : Editable a -> Bool +isEditable editable = + case editable of + Editable _ -> + True + + Readonly _ -> + False diff --git a/src/Shared/Icon.elm b/src/Shared/Icon.elm new file mode 100644 index 0000000..fa5266e --- /dev/null +++ b/src/Shared/Icon.elm @@ -0,0 +1,102 @@ +module Shared.Icon exposing (IconType(..), view) + +import Html exposing (Html) +import Svg +import Svg.Attributes as SvgAttrs + + +type IconType + = Add + | Edit + | Checkmark + | Refresh + | Remove + + +view : IconType -> Html msg +view = + iconTypeToSvg + + +iconTypeToSvg : IconType -> Html msg +iconTypeToSvg iconType = + case iconType of + Add -> + Svg.svg + [ SvgAttrs.class "w-5 h-5" + , SvgAttrs.fill "none" + , SvgAttrs.stroke "currentColor" + , SvgAttrs.viewBox "0 0 24 24" + ] + [ Svg.path + [ SvgAttrs.strokeLinecap "round" + , SvgAttrs.strokeLinejoin "round" + , SvgAttrs.strokeWidth "2" + , SvgAttrs.d "M12 4v16m8-8H4" + ] + [] + ] + + Edit -> + Svg.svg + [ SvgAttrs.class "w-4 h-4" + , SvgAttrs.fill "none" + , SvgAttrs.stroke "currentColor" + , SvgAttrs.viewBox "0 0 24 24" + ] + [ Svg.path + [ SvgAttrs.strokeLinecap "round" + , SvgAttrs.strokeLinejoin "round" + , SvgAttrs.strokeWidth "2" + , SvgAttrs.d "M15.232 5.232l3.536 3.536M16 3l5 5-12 12H4v-4L16 3z" + ] + [] + ] + + Checkmark -> + Svg.svg + [ SvgAttrs.class "w-4 h-4" + , SvgAttrs.fill "none" + , SvgAttrs.stroke "currentColor" + , SvgAttrs.viewBox "0 0 24 24" + ] + [ Svg.path + [ SvgAttrs.strokeLinecap "round" + , SvgAttrs.strokeLinejoin "round" + , SvgAttrs.strokeWidth "2" + , SvgAttrs.d "M5 13l4 4L19 7" + ] + [] + ] + + Refresh -> + Svg.svg + [ SvgAttrs.class "w-6 h-6" + , SvgAttrs.fill "currentColor" + , SvgAttrs.viewBox "0 0 16 16" + ] + [ Svg.path + [ SvgAttrs.d "M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" + ] + [] + , Svg.path + [ SvgAttrs.d "M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" + ] + [] + ] + + Remove -> + Svg.svg + [ SvgAttrs.class "w-4 h-4" + , SvgAttrs.fill "none" + , SvgAttrs.stroke "currentColor" + , SvgAttrs.viewBox "0 0 24 24" + ] + [ Svg.path + [ SvgAttrs.strokeLinecap "round" + , SvgAttrs.strokeLinejoin "round" + , SvgAttrs.strokeWidth "2" + , SvgAttrs.d "M6 18L18 6M6 6l12 12" + ] + [] + ] diff --git a/src/Shared/Tooltip.elm b/src/Shared/Tooltip.elm new file mode 100644 index 0000000..6dc0b85 --- /dev/null +++ b/src/Shared/Tooltip.elm @@ -0,0 +1,20 @@ +module Shared.Tooltip exposing (view) + +import Html exposing (Html) +import Html.Attributes as Attrs + + +type alias Tooltip = + { id : String + , text : String + } + + +view : Tooltip -> Html msg +view { id, text } = + Html.div + [ Attrs.id id + , Attrs.class "absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-100 group-focus:opacity-100 z-10 whitespace-nowrap" + , Attrs.attribute "role" "tooltip" + ] + [ Html.text text ] diff --git a/src/Shared/Value.elm b/src/Shared/Value.elm new file mode 100644 index 0000000..26db638 --- /dev/null +++ b/src/Shared/Value.elm @@ -0,0 +1,51 @@ +module Shared.Value exposing (Value, addError, fromValue, getMaybeError, isInvalid, isValid, validValue) + + +type Value a + = Valid a + | Invalid String a + + +validValue : a -> Value a +validValue = + Valid + + +fromValue : Value a -> a +fromValue value = + case value of + Valid a -> + a + + Invalid _ a -> + a + + +getMaybeError : Value a -> Maybe String +getMaybeError value = + case value of + Invalid error _ -> + Just error + + Valid _ -> + Nothing + + +isValid : Value a -> Bool +isValid value = + case value of + Invalid _ _ -> + False + + Valid _ -> + True + + +isInvalid : Value a -> Bool +isInvalid = + not << isValid + + +addError : String -> Value a -> Value a +addError error value = + Invalid error <| fromValue value diff --git a/src/app.js b/src/app.js index 62cfad4..52a017f 100644 --- a/src/app.js +++ b/src/app.js @@ -1,3 +1,5 @@ -import { Elm } from './Main.elm' +import { Elm } from './Main.elm'; -Elm.Main.init(); +Elm.Main.init({ + node: document.getElementById('elm-app'), +}); diff --git a/task-contact-details.md b/task-contact-details.md deleted file mode 100644 index abb7210..0000000 --- a/task-contact-details.md +++ /dev/null @@ -1,46 +0,0 @@ -# Task - Contact details - -## Instructions - -Your task is to implement a form for Contact details part of User group data. - -Feel free to use any package or technique you are used to or you would like to try out. - -You have a data provided to you as a `JSON` string in `Data.elm` module. They are based on what our [API](https://apidocs.scrive.com/#view-user-group) returns. - -## Requirements - -The form should display fields for all values of contact details values. - -Values for email and phone should be validated to contain proper format of values. - -User group can have one of these means of preferred contact - `post`, `email`, `phone`. - -These restrictions are in play: - - if `post` is selected then the address is mandatory to be filled - - if `email` is selected, then the email is mandatory to be filled - - if `phone` is selected then the phone is mandatory to be filled - -If settings are inherited, you should just display values without being able to edit them. - -Root user group can't use inheritance. - -## Nice to have - -- form is styled to follow nice UX/UI design -- is responsive -- is accessible - -## Data description - -Contact details data are part of the JSON and should be easy to understand what is what. - -These settings can be also inherited from any user group above. - -### Inheritance - -User groups live in a tree structure - they can have many children User Groups which can inherit Settings and Contact details. -User group with `parent_id: null` is the root user group and cannot inherit anything. - - - diff --git a/task-settings.md b/task-settings.md deleted file mode 100644 index e1204da..0000000 --- a/task-settings.md +++ /dev/null @@ -1,46 +0,0 @@ -# Task - Settings - -## Instructions - -Your task is to implement a form for Settings part of User group data. - -Feel free to use any package or technique you are used to or you would like to try out. - -You have a data provided to you as a `JSON` string in `Data.elm` module. They are based on what our [API](https://apidocs.scrive.com/#view-user-group) returns. - -## Requirements - -The form should only display fields for values that are not null in the incoming request with option to add new ones that are not there yet. Data retention settings are - -- preparation, -- closed, -- canceled, -- timed out, -- rejected, -- error - -Form should also contain the boolean for immediate trashing value. - -If settings are inherited, you should just display values without being able to edit them. - -Root user group can't use inheritance. - -## Nice to have - -- form is styled to follow nice UX/UI design -- is responsive -- is accessible - -## Data description - -This part of data controls how many days can the document stay in certain state (preparation, closed, canceled, timedout, rejected, error) before it is discarded. Value of `null` means that it is never discarded in this state. - -These settings can be also inherited from any user group above. - -### Inheritance - -User groups live in a tree structure - they can have many children User Groups which can inherit Settings and Contact details. -User group with `parent_id: null` is the root user group and cannot inherit anything. - - - diff --git a/vite.config.js b/vite.config.js index 459fcfb..5d6be55 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ -import { defineConfig } from 'vite' -import elmPlugin from 'vite-plugin-elm' +import { defineConfig } from 'vite'; +import elmPlugin from 'vite-plugin-elm'; export default defineConfig({ - plugins: [elmPlugin()] -}) + plugins: [elmPlugin()], +});