Repository: statisticsnorway/datadoc-editor (language: python)

Issue:
Improve project structure
The project structure can be further improved and clarified for ease of future development. Splitting callbacks, components, layouts and backend code into smaller files will help with ease of reading and understanding code.

This article may be used as a starting point: http://www.purfe.com/dash-project-structure-multi-tab-app-with-callbacks-in-different-files/


Discussion / hints:

Fix patch:
diff --git a/datadoc/DataDocDash.py b/datadoc/DataDocDash.py
deleted file mode 100644
index 5d31fa62..00000000
--- a/datadoc/DataDocDash.py
+++ /dev/null
@@ -1,420 +0,0 @@
-import argparse
-import logging
-import os
-import re
-from typing import Any, Dict, List, Tuple, Type
-
-import dash_bootstrap_components as dbc
-from dash import ALL, Dash, Input, Output, ctx, dash_table, dcc, html
-
-from datadoc.Enums import SupportedLanguages
-import datadoc.state as state
-from datadoc.Callbacks import (
-    accept_dataset_metadata_input,
-    accept_variable_metadata_input,
-    update_dataset_metadata_language,
-    update_variable_table_language,
-)
-from datadoc.DataDocMetadata import DataDocMetadata
-from datadoc.frontend.DisplayVariables import DISPLAY_VARIABLES
-from datadoc.frontend.DisplayDataset import (
-    NON_EDITABLE_DATASET_METADATA,
-    OBLIGATORY_EDITABLE_DATASET_METADATA,
-    OPTIONAL_DATASET_METADATA,
-    DisplayDatasetMetadata,
-)
-from datadoc.utils import running_in_notebook, get_display_values
-
-logger = logging.getLogger(__name__)
-
-
-DATASET_METADATA_INPUT = "dataset-metadata-input"
-COLORS = {"dark_1": "#F0F8F9", "green_1": "#ECFEED", "green_4": "#00824D"}
-
-
-def build_app(dash_class: Type[Dash], dataset_path: str) -> Dash:
-
-    state.metadata = DataDocMetadata(dataset_path)
-
-    app = dash_class(
-        name="DataDoc",
-        external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.BOOTSTRAP],
-        assets_folder=f"{os.path.dirname(__file__)}/assets",
-    )
-
-    def make_dataset_metadata_accordion_item(
-        title: str,
-        metadata_inputs: List[DisplayDatasetMetadata],
-    ) -> dbc.AccordionItem:
-        return dbc.AccordionItem(
-            title=title,
-            children=[
-                dbc.Row(
-                    [
-                        dbc.Col(html.Label(i.display_name)),
-                        dbc.Col(
-                            i.component(
-                                placeholder=i.description,
-                                disabled=not i.editable,
-                                id={
-                                    "type": DATASET_METADATA_INPUT,
-                                    "id": i.identifier,
-                                },
-                                **i.extra_kwargs,
-                                **(i.options or {}),
-                            ),
-                            width=5,
-                        ),
-                        dbc.Col(width=4),
-                    ]
-                )
-                for i in metadata_inputs
-            ],
-        )
-
-    def make_ssb_styled_tab(label: str, content: dbc.Container) -> dbc.Tab:
-        return dbc.Tab(
-            label=label,
-            # Replace all whitespace with dashes
-            tab_id=re.sub(r"\s+", "-", label.lower()),
-            label_class_name="ssb-tabs navigation-item",
-            label_style={"margin-left": "10px", "margin-right": "10px"},
-            style={"padding": "4px"},
-            children=content,
-        )
-
-    def make_ssb_warning_alert(
-        alert_identifier: str, title: str, content_identifier: str
-    ) -> dbc.Alert:
-        return dbc.Alert(
-            id=alert_identifier,
-            is_open=False,
-            dismissable=True,
-            fade=True,
-            class_name="ssb-dialog warning",
-            children=[
-                dbc.Row(
-                    [
-                        dbc.Col(
-                            width=1,
-                            children=[
-                                html.Div(
-                                    className="ssb-dialog warning icon-panel",
-                                    children=[
-                                        html.I(
-                                            className="bi bi-exclamation-triangle",
-                                        ),
-                                    ],
-                                )
-                            ],
-                        ),
-                        dbc.Col(
-                            [
-                                html.H5(
-                                    title,
-                                ),
-                                dcc.Markdown(
-                                    id=content_identifier,
-                                ),
-                            ]
-                        ),
-                    ],
-                )
-            ],
-            color="danger",
-        )
-
-    dataset_details = make_ssb_styled_tab(
-        "Datasett",
-        dbc.Container(
-            [
-                dbc.Row(html.H2("Datasett detaljer", className="ssb-title")),
-                dbc.Accordion(
-                    always_open=True,
-                    children=[
-                        make_dataset_metadata_accordion_item(
-                            "Obligatorisk",
-                            OBLIGATORY_EDITABLE_DATASET_METADATA,
-                        ),
-                        make_dataset_metadata_accordion_item(
-                            "Valgfritt",
-                            OPTIONAL_DATASET_METADATA,
-                        ),
-                        make_dataset_metadata_accordion_item(
-                            "Maskingenerert",
-                            NON_EDITABLE_DATASET_METADATA,
-                        ),
-                    ],
-                ),
-            ],
-        ),
-    )
-
-    variables_table = make_ssb_styled_tab(
-        "Variabler",
-        dbc.Container(
-            children=[
-                dbc.Row(html.H2("Variabel detaljer", className="ssb-title")),
-                dbc.Row(
-                    dash_table.DataTable(
-                        id="variables-table",
-                        # Populate fields with known values
-                        data=[
-                            get_display_values(v, state.current_metadata_language)
-                            for v in state.metadata.meta.variables
-                        ],
-                        # Define columns based on the information in DISPLAY_VARIABLES
-                        columns=[
-                            {
-                                "name": variable.display_name,
-                                "id": variable.identifier,
-                                "editable": variable.editable,
-                                "presentation": variable.presentation,
-                                "hideable": variable.editable,
-                            }
-                            for variable in DISPLAY_VARIABLES.values()
-                        ],
-                        # Non-obligatory variables are hidden by default
-                        hidden_columns=[
-                            v.identifier
-                            for v in DISPLAY_VARIABLES.values()
-                            if v.obligatory is False
-                        ],
-                        # Include tooltips for all columns
-                        tooltip_header={
-                            v.identifier: v.description
-                            for v in DISPLAY_VARIABLES.values()
-                        },
-                        editable=True,
-                        # Enable sorting and pagination
-                        sort_action="native",
-                        page_action="native",
-                        # Populate the options for all dropdown values
-                        dropdown={
-                            v.identifier: v.options
-                            for v in DISPLAY_VARIABLES.values()
-                            if v.options
-                        },
-                    )
-                ),
-            ],
-        ),
-    )
-
-    header = dbc.CardBody(
-        dbc.Row(
-            children=[
-                # html.Link(rel="stylesheet", href="assets/bundle.css"),
-                html.H1("DataDoc", className="ssb-title", style={"color": "white"}),
-            ],
-        ),
-        style={"backgroundColor": COLORS["green_4"]},
-    )
-
-    progress_bar = dbc.CardBody(
-        style={"padding": "4px"},
-        children=[dbc.Progress(id="progress-bar", color=COLORS["green_4"], value=40)],
-    )
-
-    controls_bar = dbc.CardBody(
-        style={"padding": "4px"},
-        children=[
-            dbc.Row(
-                [
-                    dbc.Col(
-                        dbc.Button(
-                            [
-                                html.I(
-                                    className="bi bi-save",
-                                    style={"padding-right": "10px"},
-                                ),
-                                "   Lagre",
-                            ],
-                            class_name="ssb-btn primary-btn",
-                            id="save-button",
-                        ),
-                    ),
-                    dbc.Col(
-                        dcc.Dropdown(
-                            id="language-dropdown",
-                            searchable=False,
-                            value=state.current_metadata_language.value,
-                            className="ssb-dropdown",
-                            options=[
-                                {"label": i.name, "value": i.value}
-                                for i in SupportedLanguages
-                            ],
-                        ),
-                        align="end",
-                        width="auto",
-                    ),
-                ]
-            )
-        ],
-    )
-    dataset_validation_error = make_ssb_warning_alert(
-        "dataset-validation-error",
-        "Failed validation",
-        "dataset-validation-explanation",
-    )
-
-    variables_validation_error = make_ssb_warning_alert(
-        "variables-validation-error",
-        "Failed validation",
-        "variables-validation-explanation",
-    )
-
-    success_toast = dbc.Alert(
-        id="success-message",
-        is_open=False,
-        dismissable=True,
-        fade=True,
-        class_name="ssb-dialog",
-        children=[
-            dbc.Row(
-                [
-                    dbc.Col(
-                        width=3,
-                        children=[
-                            html.Div(
-                                className="ssb-dialog icon-panel",
-                                children=[
-                                    html.I(
-                                        className="bi bi-check-circle",
-                                    ),
-                                ],
-                            )
-                        ],
-                    ),
-                    dbc.Col(
-                        [
-                            html.H5(
-                                "Lagret metadata",
-                            ),
-                        ]
-                    ),
-                ],
-                align="center",
-            )
-        ],
-        color="success",
-    )
-
-    app.layout = dbc.Container(
-        style={"padding": "4px"},
-        children=[
-            header,
-            progress_bar,
-            controls_bar,
-            dbc.CardBody(
-                style={"padding": "4px"},
-                children=[
-                    dbc.Tabs(
-                        id="tabs",
-                        class_name="ssb-tabs",
-                        children=[
-                            dataset_details,
-                            variables_table,
-                        ],
-                    ),
-                ],
-            ),
-            variables_validation_error,
-            dataset_validation_error,
-            success_toast,
-        ],
-    )
-
-    @app.callback(
-        Output("progress-bar", "value"),
-        Output("progress-bar", "label"),
-        Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"),
-        Input("variables-table", "data"),
-    )
-    def callback_update_progress(value, data) -> Tuple[int, str]:
-        completion = state.metadata.percent_complete
-        return completion, f"{completion}%"
-
-    @app.callback(
-        Output("success-message", "is_open"),
-        Input("save-button", "n_clicks"),
-        prevent_initial_call=True,
-    )
-    def callback_save_metadata_file(n_clicks):
-        if n_clicks and n_clicks > 0:
-            # Write the final completion percentage to the model
-            state.metadata.meta.percentage_complete = state.metadata.percent_complete
-            state.metadata.write_metadata_document()
-            return True
-        else:
-            return False
-
-    @app.callback(
-        Output(
-            {"type": DATASET_METADATA_INPUT, "id": ALL},
-            "value",
-        ),
-        Input("language-dropdown", "value"),
-    )
-    def callback_change_language(language: str):
-        return update_dataset_metadata_language(SupportedLanguages(language))
-
-    @app.callback(
-        Output("dataset-validation-error", "is_open"),
-        Output("dataset-validation-explanation", "children"),
-        Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"),
-        prevent_initial_call=True,
-    )
-    def callback_accept_dataset_metadata_input(value: Any) -> Tuple[bool, str]:
-        # Get the ID of the input that changed. This MUST match the attribute name defined in DataDocDataSet
-        return accept_dataset_metadata_input(
-            ctx.triggered[0]["value"], ctx.triggered_id["id"]
-        )
-
-    @app.callback(
-        Output("variables-table", "data"),
-        Output("variables-validation-error", "is_open"),
-        Output("variables-validation-explanation", "children"),
-        Input("variables-table", "data"),
-        Input("variables-table", "data_previous"),
-        Input("language-dropdown", "value"),
-        prevent_initial_call=True,
-    )
-    def callback_variable_table(
-        data: List[Dict], data_previous: List[Dict], language: str
-    ) -> Tuple[List[Dict], bool, str]:
-        if ctx.triggered_id == "language-dropdown":
-            return update_variable_table_language(data, SupportedLanguages(language))
-        else:
-            return accept_variable_metadata_input(data, data_previous)
-
-    return app
-
-
-def main(dataset_path: str = None):
-    if dataset_path is None:
-        parser = argparse.ArgumentParser()
-        parser.add_argument("--dataset-path", help="Specify the path to a dataset")
-        args = parser.parse_args()
-        # Use example dataset if nothing specified
-        dataset = args.dataset_path or "./klargjorte_data/person_data_v1.parquet"
-    else:
-        dataset = dataset_path
-
-    if running_in_notebook():
-        logging.basicConfig(level=logging.WARNING)
-        from jupyter_dash import JupyterDash
-
-        JupyterDash.infer_jupyter_proxy_config()
-        app = build_app(JupyterDash, dataset)
-        app.run_server(mode="inline")
-    else:
-        logging.basicConfig(level=logging.DEBUG)
-        # Assume running in server mode is better (largely for development purposes)
-        logger.debug("Starting in development mode")
-        app = build_app(Dash, dataset)
-        app.run_server(debug=True)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/datadoc/__init__.py b/datadoc/__init__.py
index a146162d..f26c7f5f 100644
--- a/datadoc/__init__.py
+++ b/datadoc/__init__.py
@@ -1,3 +1,3 @@
-from datadoc.DataDocDash import main
+from datadoc.app import main
 
 __version__ = "0.1.1"
diff --git a/datadoc/app.py b/datadoc/app.py
new file mode 100644
index 00000000..f202ba78
--- /dev/null
+++ b/datadoc/app.py
@@ -0,0 +1,99 @@
+import argparse
+import logging
+import os
+from typing import Type
+
+import dash_bootstrap_components as dbc
+from dash import Dash
+
+from datadoc.Enums import SupportedLanguages
+from datadoc.frontend.components.DatasetTab import get_dataset_tab
+from datadoc.frontend.components.VariablesTab import get_variables_tab
+from datadoc.frontend.components.Alerts import (
+    dataset_validation_error,
+    variables_validation_error,
+    success_toast,
+)
+from datadoc.frontend.components.HeaderBars import (
+    header,
+    progress_bar,
+    get_controls_bar,
+)
+import datadoc.state as state
+from datadoc.frontend.callbacks.Callbacks import register_callbacks
+from datadoc.backend.DataDocMetadata import DataDocMetadata
+from datadoc.utils import running_in_notebook
+
+logger = logging.getLogger(__name__)
+
+NAME = "Datadoc"
+
+
+def build_app(dash_class: Type[Dash]) -> Dash:
+
+    app = dash_class(
+        name=NAME,
+        title=NAME,
+        external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.BOOTSTRAP],
+        assets_folder=f"{os.path.dirname(__file__)}/assets",
+    )
+
+    app.layout = dbc.Container(
+        style={"padding": "4px"},
+        children=[
+            header,
+            progress_bar,
+            get_controls_bar(),
+            dbc.CardBody(
+                style={"padding": "4px"},
+                children=[
+                    dbc.Tabs(
+                        id="tabs",
+                        class_name="ssb-tabs",
+                        children=[
+                            get_dataset_tab(),
+                            get_variables_tab(),
+                        ],
+                    ),
+                ],
+            ),
+            variables_validation_error,
+            dataset_validation_error,
+            success_toast,
+        ],
+    )
+
+    register_callbacks(app)
+
+    return app
+
+
+def main(dataset_path: str = None):
+    if dataset_path is None:
+        # Get the supplied command line argument
+        parser = argparse.ArgumentParser()
+        parser.add_argument("--dataset-path", help="Specify the path to a dataset")
+        args = parser.parse_args()
+        # Use example dataset if nothing specified
+        dataset = args.dataset_path or "./klargjorte_data/person_data_v1.parquet"
+    else:
+        dataset = dataset_path
+
+    state.metadata = DataDocMetadata(dataset)
+
+    if running_in_notebook():
+        logging.basicConfig(level=logging.WARNING)
+        from jupyter_dash import JupyterDash
+        JupyterDash.infer_jupyter_proxy_config()
+        app = build_app(JupyterDash)
+        app.run_server(mode="inline")
+    else:
+        # Assume running in server mode is better (largely for development purposes)
+        logging.basicConfig(level=logging.DEBUG)
+        logger.debug("Starting in development mode")
+        app = build_app(Dash)
+        app.run_server(debug=True)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/datadoc/DataDocMetadata.py b/datadoc/backend/DataDocMetadata.py
similarity index 94%
rename from datadoc/DataDocMetadata.py
rename to datadoc/backend/DataDocMetadata.py
index c0102a17..33b8f803 100644
--- a/datadoc/DataDocMetadata.py
+++ b/datadoc/backend/DataDocMetadata.py
@@ -9,10 +9,10 @@
     AdministrativeStatus,
     DatasetState,
 )
-from datadoc.DatasetReader import DatasetReader
+from datadoc.backend.DatasetReader import DatasetReader
 from datadoc_model import Model
-import datadoc.frontend.DisplayDataset as DisplayDataset
-import datadoc.frontend.DisplayVariables as DisplayVariables
+import datadoc.frontend.fields.DisplayDataset as DisplayDataset
+import datadoc.frontend.fields.DisplayVariables as DisplayVariables
 from datadoc.utils import calculate_percentage
 
 logger = logging.getLogger(__name__)
@@ -47,14 +47,13 @@ def __init__(self, dataset):
         self.dataset = dataset
         self.dataset_full_path = pathlib.Path(self.dataset)
         self.dataset_directory = self.dataset_full_path.resolve().parent
-        self.dataset_name = self.dataset_full_path.name
         self.dataset_stem = self.dataset_full_path.stem  # filename without file ending
         self.metadata_document_name = str(self.dataset_stem) + "__DOC.json"
         self.metadata_document_full_path = self.dataset_directory.joinpath(
             self.metadata_document_name
         )
         self.dataset_state = self.get_dataset_state(self.dataset_full_path)
-        self.dataset_version = self.get_dataset_version()
+        self.dataset_version = self.get_dataset_version(self.dataset_stem)
         try:
             self.current_user = os.environ["JUPYTERHUB_USER"]
         except KeyError:
@@ -92,10 +91,10 @@ def get_dataset_state(
         else:
             return None
 
-    def get_dataset_version(self) -> Optional[str]:
+    def get_dataset_version(self, dataset_stem: str) -> Optional[str]:
         """Find version information if exists in filename,
         eg. 'v1' in filename 'person_data_v1.parquet'"""
-        splitted_file_name = str(self.dataset_stem).split("_")
+        splitted_file_name = str(dataset_stem).split("_")
         if len(splitted_file_name) >= 2:
             last_filename_element = str(splitted_file_name[-1])
             if (
diff --git a/datadoc/DatasetReader.py b/datadoc/backend/DatasetReader.py
similarity index 72%
rename from datadoc/DatasetReader.py
rename to datadoc/backend/DatasetReader.py
index 06fad85c..d99ffbd7 100644
--- a/datadoc/DatasetReader.py
+++ b/datadoc/backend/DatasetReader.py
@@ -6,7 +6,7 @@
 from datadoc import state
 
 from datadoc_model.Enums import Datatype
-from datadoc_model.Model import DataDocVariable, LanguageStrings
+from datadoc_model.Model import DataDocVariable
 
 TDatasetReader = TypeVar("TDatasetReader", bound="DatasetReader")
 
@@ -72,14 +72,25 @@ def __init__(self, dataset):
     @staticmethod
     def for_file(dataset: str) -> TDatasetReader:
         """Factory method to return the correct subclass based on the given dataset file"""
-        file_type = str(pathlib.Path(dataset)).lower().split(".")[1]
-        if file_type == "parquet":
-            return DatasetReaderParquet(dataset)
-        if file_type == "sas7bdat":
-            return DatasetReaderSas7bdat(dataset)
-        else:
-            # In the future we can potentially support csv, xml or json files
-            raise NotImplementedError
+        SUPPORTED_FILE_TYPES = {
+            "parquet": DatasetReaderParquet,
+            "sas7bdat": DatasetReaderSas7bdat,
+        }
+        try:
+            file_type = str(pathlib.Path(dataset)).lower().split(".")[1]
+        except IndexError as e:
+            # Thrown when just one element is returned from split, meaning there is no file extension supplied
+            raise FileNotFoundError(
+                f"Could not recognise file type for provided {dataset = }. Supported file types are: {', '.join(SUPPORTED_FILE_TYPES.keys())}"
+            ) from e
+        try:
+            # Extract the appropriate reader class from the SUPPORTED_FILE_TYPES dict and return and instance of it
+            return SUPPORTED_FILE_TYPES[file_type](dataset)
+        except KeyError:
+            # In this case the file type is not supported so we throw a helpful exception
+            raise NotImplementedError(
+                f"{file_type = } is not supported. Please open one of the following supported files types: {', '.join(SUPPORTED_FILE_TYPES.keys())} or contact the maintainers to request support."
+            )
 
     @staticmethod
     def transform_data_type(data_type: str) -> Optional[Datatype]:
@@ -95,7 +106,9 @@ def transform_data_type(data_type: str) -> Optional[Datatype]:
         elif v_data_type in KNOWN_BOOLEAN_TYPES:
             return Datatype.BOOLEAN
         else:
-            return None  # Unknown datatype?
+            # Unknown data type. There's no need to throw an exception here,
+            # the user can still define the data type manually in the GUI
+            return None
 
     @abstractmethod
     def get_fields(self) -> List[DataDocVariable]:
diff --git a/datadoc/VariableDefinition.py b/datadoc/backend/VariableDefinition.py
similarity index 100%
rename from datadoc/VariableDefinition.py
rename to datadoc/backend/VariableDefinition.py
diff --git a/datadoc/frontend/Builders.py b/datadoc/frontend/Builders.py
new file mode 100644
index 00000000..581595fb
--- /dev/null
+++ b/datadoc/frontend/Builders.py
@@ -0,0 +1,93 @@
+import re
+from typing import List
+import dash_bootstrap_components as dbc
+from dash import html, dcc
+
+from datadoc.frontend.fields.DisplayDataset import DisplayDatasetMetadata
+
+DATASET_METADATA_INPUT = "dataset-metadata-input"
+
+
+def make_dataset_metadata_accordion_item(
+    title: str,
+    metadata_inputs: List[DisplayDatasetMetadata],
+) -> dbc.AccordionItem:
+    return dbc.AccordionItem(
+        title=title,
+        children=[
+            dbc.Row(
+                [
+                    dbc.Col(html.Label(i.display_name)),
+                    dbc.Col(
+                        i.component(
+                            placeholder=i.description,
+                            disabled=not i.editable,
+                            id={
+                                "type": DATASET_METADATA_INPUT,
+                                "id": i.identifier,
+                            },
+                            **i.extra_kwargs,
+                            **(i.options or {}),
+                        ),
+                        width=5,
+                    ),
+                    dbc.Col(width=4),
+                ]
+            )
+            for i in metadata_inputs
+        ],
+    )
+
+
+def make_ssb_styled_tab(label: str, content: dbc.Container) -> dbc.Tab:
+    return dbc.Tab(
+        label=label,
+        # Replace all whitespace with dashes
+        tab_id=re.sub(r"\s+", "-", label.lower()),
+        label_class_name="ssb-tabs navigation-item",
+        label_style={"margin-left": "10px", "margin-right": "10px"},
+        style={"padding": "4px"},
+        children=content,
+    )
+
+
+def make_ssb_warning_alert(
+    alert_identifier: str, title: str, content_identifier: str
+) -> dbc.Alert:
+    return dbc.Alert(
+        id=alert_identifier,
+        is_open=False,
+        dismissable=True,
+        fade=True,
+        class_name="ssb-dialog warning",
+        children=[
+            dbc.Row(
+                [
+                    dbc.Col(
+                        width=1,
+                        children=[
+                            html.Div(
+                                className="ssb-dialog warning icon-panel",
+                                children=[
+                                    html.I(
+                                        className="bi bi-exclamation-triangle",
+                                    ),
+                                ],
+                            )
+                        ],
+                    ),
+                    dbc.Col(
+                        [
+                            html.H5(
+                                title,
+                            ),
+                            dcc.Markdown(
+                                id=content_identifier,
+                            ),
+                        ]
+                    ),
+                ],
+            )
+        ],
+        color="danger",
+    )
diff --git a/datadoc/frontend/Components.py b/datadoc/frontend/Components.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/datadoc/Callbacks.py b/datadoc/frontend/callbacks/Callbacks.py
similarity index 66%
rename from datadoc/Callbacks.py
rename to datadoc/frontend/callbacks/Callbacks.py
index d574f51a..7c33db9d 100644
--- a/datadoc/Callbacks.py
+++ b/datadoc/frontend/callbacks/Callbacks.py
@@ -2,26 +2,30 @@
 from typing import Any, Dict, List, Optional, Tuple
 from pydantic import ValidationError
 from datadoc_model import Model
+from dash import Dash, Output, Input, ctx, ALL
 
 import datadoc.state as state
 from datadoc.Enums import SupportedLanguages
 from datadoc.utils import get_display_values
-from datadoc.frontend.DisplayDataset import (
+from datadoc.frontend.fields.DisplayDataset import (
     MULTIPLE_LANGUAGE_DATASET_METADATA,
     NON_EDITABLE_DATASET_METADATA,
     OBLIGATORY_EDITABLE_DATASET_METADATA,
     OPTIONAL_DATASET_METADATA,
     DisplayDatasetMetadata,
 )
-from datadoc.frontend.DisplayVariables import (
+from datadoc.frontend.fields.DisplayVariables import (
     MULTIPLE_LANGUAGE_VARIABLES_METADATA,
     VariableIdentifiers,
 )
+from datadoc.frontend.Builders import (
+    DATASET_METADATA_INPUT,
+)
 
 logger = logging.getLogger(__name__)
 
 
-def store_language_string(
+def find_existing_language_string(
     metadata_model_object: "Model.DataDocBaseModel",
     value: str,
     metadata_identifier: str,
@@ -64,7 +68,7 @@ def accept_variable_metadata_input(
         try:
             if updated_column_id in MULTIPLE_LANGUAGE_VARIABLES_METADATA:
                 if type(new_value) is str:
-                    new_value = store_language_string(
+                    new_value = find_existing_language_string(
                         state.metadata.variables_lookup[updated_row_id],
                         new_value,
                         updated_column_id,
@@ -73,7 +77,7 @@ def accept_variable_metadata_input(
                     # This edge case occurs when the user removes the text in an input field
                     # We want to ensure we only remove the content for the current language,
                     # not create a new blank object!
-                    new_value = store_language_string(
+                    new_value = find_existing_language_string(
                         state.metadata.variables_lookup[updated_row_id],
                         "",
                         updated_column_id,
@@ -107,7 +111,7 @@ def accept_dataset_metadata_input(
             metadata_identifier in MULTIPLE_LANGUAGE_DATASET_METADATA
             and type(value) is str
         ):
-            value = store_language_string(
+            value = find_existing_language_string(
                 state.metadata.meta.dataset, value, metadata_identifier
             )
 
@@ -166,3 +170,68 @@ def update_variable_table_language(
         )
     logger.debug(f"Updated variable table language: {language.name}")
     return new_data, False, ""
+
+
+def register_callbacks(app: Dash) -> None:
+    @app.callback(
+        Output("progress-bar", "value"),
+        Output("progress-bar", "label"),
+        Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"),
+        Input("variables-table", "data"),
+    )
+    def callback_update_progress(value, data) -> Tuple[int, str]:
+        completion = state.metadata.percent_complete
+        return completion, f"{completion}%"
+
+    @app.callback(
+        Output("success-message", "is_open"),
+        Input("save-button", "n_clicks"),
+        prevent_initial_call=True,
+    )
+    def callback_save_metadata_file(n_clicks):
+        if n_clicks and n_clicks > 0:
+            # Write the final completion percentage to the model
+            state.metadata.meta.percentage_complete = state.metadata.percent_complete
+            state.metadata.write_metadata_document()
+            return True
+        else:
+            return False
+
+    @app.callback(
+        Output(
+            {"type": DATASET_METADATA_INPUT, "id": ALL},
+            "value",
+        ),
+        Input("language-dropdown", "value"),
+    )
+    def callback_change_language(language: str):
+        return update_dataset_metadata_language(SupportedLanguages(language))
+
+    @app.callback(
+        Output("dataset-validation-error", "is_open"),
+        Output("dataset-validation-explanation", "children"),
+        Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"),
+        prevent_initial_call=True,
+    )
+    def callback_accept_dataset_metadata_input(value: Any) -> Tuple[bool, str]:
+        # Get the ID of the input that changed. This MUST match the attribute name defined in DataDocDataSet
+        return accept_dataset_metadata_input(
+            ctx.triggered[0]["value"], ctx.triggered_id["id"]
+        )
+
+    @app.callback(
+        Output("variables-table", "data"),
+        Output("variables-validation-error", "is_open"),
+        Output("variables-validation-explanation", "children"),
+        Input("variables-table", "data"),
+        Input("variables-table", "data_previous"),
+        Input("language-dropdown", "value"),
+        prevent_initial_call=True,
+    )
+    def callback_variable_table(
+        data: List[Dict], data_previous: List[Dict], language: str
+    ) -> Tuple[List[Dict], bool, str]:
+        if ctx.triggered_id == "language-dropdown":
+            return update_variable_table_language(data, SupportedLanguages(language))
+        else:
+            return accept_variable_metadata_input(data, data_previous)
diff --git a/datadoc/frontend/components/Alerts.py b/datadoc/frontend/components/Alerts.py
new file mode 100644
index 00000000..055ef2a5
--- /dev/null
+++ b/datadoc/frontend/components/Alerts.py
@@ -0,0 +1,52 @@
+import dash_bootstrap_components as dbc
+from dash import html
+
+from datadoc.frontend.Builders import make_ssb_warning_alert
+
+dataset_validation_error = make_ssb_warning_alert(
+    "dataset-validation-error",
+    "Failed validation",
+    "dataset-validation-explanation",
+)
+
+variables_validation_error = make_ssb_warning_alert(
+    "variables-validation-error",
+    "Failed validation",
+    "variables-validation-explanation",
+)
+
+success_toast = dbc.Alert(
+    id="success-message",
+    is_open=False,
+    dismissable=True,
+    fade=True,
+    class_name="ssb-dialog",
+    children=[
+        dbc.Row(
+            [
+                dbc.Col(
+                    width=3,
+                    children=[
+                        html.Div(
+                            className="ssb-dialog icon-panel",
+                            children=[
+                                html.I(
+                                    className="bi bi-check-circle",
+                                ),
+                            ],
+                        )
+                    ],
+                ),
+                dbc.Col(
+                    [
+                        html.H5(
+                            "Lagret metadata",
+                        ),
+                    ]
+                ),
+            ],
+            align="center",
+        )
+    ],
+    color="success",
+)
diff --git a/datadoc/frontend/components/DatasetTab.py b/datadoc/frontend/components/DatasetTab.py
new file mode 100644
index 00000000..cf0423da
--- /dev/null
+++ b/datadoc/frontend/components/DatasetTab.py
@@ -0,0 +1,43 @@
+import dash_bootstrap_components as dbc
+from dash import html, dash_table
+
+from datadoc.frontend.Builders import (
+    make_dataset_metadata_accordion_item,
+    make_ssb_styled_tab,
+)
+from datadoc.frontend.fields.DisplayDataset import (
+    NON_EDITABLE_DATASET_METADATA,
+    OBLIGATORY_EDITABLE_DATASET_METADATA,
+    OPTIONAL_DATASET_METADATA,
+)
+from datadoc.frontend.fields.DisplayVariables import DISPLAY_VARIABLES
+from datadoc.utils import get_display_values
+import datadoc.state as state
+
+
+def get_dataset_tab() -> dbc.Tab:
+    return make_ssb_styled_tab(
+        "Datasett",
+        dbc.Container(
+            [
+                dbc.Row(html.H2("Datasett detaljer", className="ssb-title")),
+                dbc.Accordion(
+                    always_open=True,
+                    children=[
+                        make_dataset_metadata_accordion_item(
+                            "Obligatorisk",
+                            OBLIGATORY_EDITABLE_DATASET_METADATA,
+                        ),
+                        make_dataset_metadata_accordion_item(
+                            "Valgfritt",
+                            OPTIONAL_DATASET_METADATA,
+                        ),
+                        make_dataset_metadata_accordion_item(
+                            "Maskingenerert",
+                            NON_EDITABLE_DATASET_METADATA,
+                        ),
+                    ],
+                ),
+            ],
+        ),
+    )
diff --git a/datadoc/frontend/components/HeaderBars.py b/datadoc/frontend/components/HeaderBars.py
new file mode 100644
index 00000000..3134b4fb
--- /dev/null
+++ b/datadoc/frontend/components/HeaderBars.py
@@ -0,0 +1,62 @@
+import dash_bootstrap_components as dbc
+from dash import html, dcc
+
+from datadoc.Enums import SupportedLanguages
+import datadoc.state as state
+
+
+COLORS = {"dark_1": "#F0F8F9", "green_1": "#ECFEED", "green_4": "#00824D"}
+
+header = dbc.CardBody(
+    dbc.Row(
+        children=[
+            # html.Link(rel="stylesheet", href="assets/bundle.css"),
+            html.H1("DataDoc", className="ssb-title", style={"color": "white"}),
+        ],
+    ),
+    style={"backgroundColor": COLORS["green_4"]},
+)
+
+progress_bar = dbc.CardBody(
+    style={"padding": "4px"},
+    children=[dbc.Progress(id="progress-bar", color=COLORS["green_4"], value=40)],
+)
+
+
+def get_controls_bar() -> dbc.CardBody:
+    return dbc.CardBody(
+        style={"padding": "4px"},
+        children=[
+            dbc.Row(
+                [
+                    dbc.Col(
+                        dbc.Button(
+                            [
+                                html.I(
+                                    className="bi bi-save",
+                                    style={"padding-right": "10px"},
+                                ),
+                                "   Lagre",
+                            ],
+                            class_name="ssb-btn primary-btn",
+                            id="save-button",
+                        ),
+                    ),
+                    dbc.Col(
+                        dcc.Dropdown(
+                            id="language-dropdown",
+                            searchable=False,
+                            value=state.current_metadata_language.value,
+                            className="ssb-dropdown",
+                            options=[
+                                {"label": i.name, "value": i.value}
+                                for i in SupportedLanguages
+                            ],
+                        ),
+                        align="end",
+                        width="auto",
+                    ),
+                ]
+            )
+        ],
+    )
diff --git a/datadoc/frontend/components/VariablesTab.py b/datadoc/frontend/components/VariablesTab.py
new file mode 100644
index 00000000..5db7b7e0
--- /dev/null
+++ b/datadoc/frontend/components/VariablesTab.py
@@ -0,0 +1,60 @@
+import dash_bootstrap_components as dbc
+from dash import html, dash_table
+
+from datadoc.frontend.Builders import make_ssb_styled_tab
+from datadoc.frontend.fields.DisplayVariables import DISPLAY_VARIABLES
+from datadoc.utils import get_display_values
+import datadoc.state as state
+
+
+def get_variables_tab() -> dbc.Tab:
+    return make_ssb_styled_tab(
+        "Variabler",
+        dbc.Container(
+            children=[
+                dbc.Row(html.H2("Variabel detaljer", className="ssb-title")),
+                dbc.Row(
+                    dash_table.DataTable(
+                        id="variables-table",
+                        # Populate fields with known values
+                        data=[
+                            get_display_values(v, state.current_metadata_language)
+                            for v in state.metadata.meta.variables
+                        ],
+                        # Define columns based on the information in DISPLAY_VARIABLES
+                        columns=[
+                            {
+                                "name": variable.display_name,
+                                "id": variable.identifier,
+                                "editable": variable.editable,
+                                "presentation": variable.presentation,
+                                "hideable": variable.editable,
+                            }
+                            for variable in DISPLAY_VARIABLES.values()
+                        ],
+                        # Non-obligatory variables are hidden by default
+                        hidden_columns=[
+                            v.identifier
+                            for v in DISPLAY_VARIABLES.values()
+                            if v.obligatory is False
+                        ],
+                        # Include tooltips for all columns
+                        tooltip_header={
+                            v.identifier: v.description
+                            for v in DISPLAY_VARIABLES.values()
+                        },
+                        editable=True,
+                        # Enable sorting and pagination
+                        sort_action="native",
+                        page_action="native",
+                        # Populate the options for all dropdown values
+                        dropdown={
+                            v.identifier: v.options
+                            for v in DISPLAY_VARIABLES.values()
+                            if v.options
+                        },
+                    )
+                ),
+            ],
+        ),
+    )
diff --git a/datadoc/frontend/DisplayBase.py b/datadoc/frontend/fields/DisplayBase.py
similarity index 100%
rename from datadoc/frontend/DisplayBase.py
rename to datadoc/frontend/fields/DisplayBase.py
diff --git a/datadoc/frontend/DisplayDataset.py b/datadoc/frontend/fields/DisplayDataset.py
similarity index 99%
rename from datadoc/frontend/DisplayDataset.py
rename to datadoc/frontend/fields/DisplayDataset.py
index 81dfd13b..d70a73ad 100644
--- a/datadoc/frontend/DisplayDataset.py
+++ b/datadoc/frontend/fields/DisplayDataset.py
@@ -8,7 +8,7 @@
     UnitType,
 )
 from datadoc import state
-from datadoc.frontend.DisplayBase import (
+from datadoc.frontend.fields.DisplayBase import (
     DisplayDatasetMetadata,
     DROPDOWN_KWARGS,
     NUMBER_KWARGS,
diff --git a/datadoc/frontend/DisplayVariables.py b/datadoc/frontend/fields/DisplayVariables.py
similarity index 98%
rename from datadoc/frontend/DisplayVariables.py
rename to datadoc/frontend/fields/DisplayVariables.py
index 24f62f22..47905783 100644
--- a/datadoc/frontend/DisplayVariables.py
+++ b/datadoc/frontend/fields/DisplayVariables.py
@@ -1,7 +1,7 @@
 from enum import Enum
 
 from datadoc_model.Enums import Datatype, TemporalityType, VariableRole
-from datadoc.frontend.DisplayBase import DisplayVariablesMetadata
+from datadoc.frontend.fields.DisplayBase import DisplayVariablesMetadata
 
 
 class VariableIdentifiers(str, Enum):
diff --git a/datadoc/state.py b/datadoc/state.py
index e26db896..405a6af5 100644
--- a/datadoc/state.py
+++ b/datadoc/state.py
@@ -4,7 +4,7 @@
 if TYPE_CHECKING:
     # This is only needed for a type hint so we put the import inside
     # this check to avoid circular imports
-    from datadoc.DataDocMetadata import DataDocMetadata
+    from datadoc.backend.DataDocMetadata import DataDocMetadata
 
 # DANGER: This global is safe when Datadoc is run as designed, with
 # an individual instance per user run within a Jupyter Notebook.
diff --git a/klargjorte_data/person_data_v1__DOC_example_1.json b/klargjorte_data/person_data_v1__DOC_example_1.json
index 20b6e1d9..d8f2f31d 100644
--- a/klargjorte_data/person_data_v1__DOC_example_1.json
+++ b/klargjorte_data/person_data_v1__DOC_example_1.json
@@ -1,163 +1,232 @@
 {
-    "dataSourcePath": "klargjorte_data/person_data_v1.parquet",
-    "shortName": "person_data_v1",
-    "name": [
-        {
-            "languageCode": "no",
-            "value": "Persondata"
-        }
-    ],
-    "dataSetState": "PROCESSED_DATA",
-    "description": [
-        {
-            "languageCode": "no",
-            "value": "Testdata personer for demo og test."
-        }
-    ],
-    "temporalityType": null,
-    "spatialCoverageDescription": [
-        {
-            "languageCode": "no",
-            "value": "Norge"
-        }
-    ],
-    "populationDescription": [],
-    "id": null,
-    "createdDate": "2022-03-30 12:58:16.605900",
-    "createdBy": "bnj@ssb.no",
-    "dataOwner": null,
-    "lastUpdatedDate": null,
-    "lastUpdatedBy": null,
-    "version": "1",
-    "administrativeStatus": "DRAFT",
+    "percentage_complete": 67,
+    "document_version": "1",
+    "dataset": {
+        "short_name": "person_data_v1",
+        "assessment": null,
+        "dataset_status": "DRAFT",
+        "dataset_state": "PROCESSED_DATA",
+        "name": {
+            "en": "Citizens' tax data",
+            "nn": "",
+            "nb": "Persondata"
+        },
+        "data_source": null,
+        "population_description": null,
+        "version": "1",
+        "unit_type": "PERSON",
+        "temporality_type": null,
+        "description": {
+            "en": "Test data about Norwegian citizens. To be used for demonstration and testing only.",
+            "nn": "",
+            "nb": "Testdata personer for demo og test."
+        },
+        "subject_field": null,
+        "spatial_coverage_description": {
+            "en": "Norway",
+            "nn": "",
+            "nb": "Norge"
+        },
+        "id": null,
+        "owner": null,
+        "data_source_path": "klargjorte_data/person_data_v1.parquet",
+        "created_date": "2022-08-08 13:44:14.768463",
+        "created_by": "default_user@ssb.no",
+        "last_updated_date": null,
+        "last_updated_by": null
+    },
     "variables": [
         {
-            "shortName": "pers_id",
-            "name": "F\u00f8dselsnummer",
-            "dataType": "STRING",
-            "variableRole": "IDENTIFIER",
-            "definitionUri": "26",
-            "comment": "Variabelen viser f\u00f8dselsnummer, det vil si f\u00f8dselsdag, -m\u00e5ned og -\u00e5r (6 siffer) og personnummer (5 siffer)",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "pers_id",
+            "name": {
+                "en": "Personal number",
+                "nn": "",
+                "nb": "F\u00f8dselsnummer"
+            },
+            "data_type": "STRING",
+            "variable_role": "IDENTIFIER",
+            "definition_uri": null,
+            "direct_person_identifying": true,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "tidspunkt",
-            "name": "Tidspunkt",
-            "dataType": "DATETIME",
-            "variableRole": "START_TIME",
-            "definitionUri": null,
-            "comment": "M\u00e5lt 31.12. hvert \u00e5r",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "tidspunkt",
+            "name": {
+                "en": "Timestamp",
+                "nn": "",
+                "nb": "Tidspunkt"
+            },
+            "data_type": "DATETIME",
+            "variable_role": "START_TIME",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "sivilstand",
-            "name": "Sivilstand",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "91",
-            "comment": "Variabelen viser en persons stilling i forhold til ekteskapslovgivningen",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "sivilstand",
+            "name": {
+                "en": "Marital status",
+                "nn": "",
+                "nb": "Sivilstand"
+            },
+            "data_type": "STRING",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "alm_inntekt",
-            "name": "Alminnelig inntekt",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "1438",
-            "comment": "Alminnelig inntekt er alle skattepliktige inntekter fratrukket skattemessige fradrag. Positiv alminnelig inntekt er grunnlaget for utligning av inntektsskatt.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "alm_inntekt",
+            "name": {
+                "en": "Gross income",
+                "nn": "",
+                "nb": "Almennlig inntekt"
+            },
+            "data_type": "INTEGER",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "sykepenger",
-            "name": "Sykepenger",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "3366",
-            "comment": "Sykepenger fra folketrygden (utbetalt av NAV), som kompensasjon for bortfall av arbeidsinntekt for arbeidstakere i en sykdomsperiode . Dette inkluderer ikke arbeidsgiverperioden, der de f\u00f8rste 16 dagene av sykemeldingen dekkes av arbeidsgiver. Feriepenger av sykepenger er medregnet.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "sykepenger",
+            "name": {
+                "en": "Sick pay",
+                "nn": "",
+                "nb": "Sykepenger"
+            },
+            "data_type": "INTEGER",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "ber_bruttoformue",
-            "name": "Beregnet bruttoformue",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "3327",
-            "comment": "Omfatter summen av beregnet realkapital og beregnet bruttofinanskapital.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "ber_bruttoformue",
+            "name": {
+                "en": "Gross worth",
+                "nn": "",
+                "nb": "Beregnet bruttoformue"
+            },
+            "data_type": "INTEGER",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "fullf_utdanning",
-            "name": "Fullf\u00f8rt utdanning",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "3242",
-            "comment": "Fullf\u00f8rt utdanning betyr at eleven/studenten har f\u00e5tt vitnem\u00e5l/kursbevis eller p\u00e5 annen m\u00e5te fyller de krav som stilles til gjennomg\u00e5tt og best\u00e5tt utdanning",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "fullf_utdanning",
+            "name": {
+                "en": "Completed education",
+                "nn": "",
+                "nb": "Fullf\u00f8rt utdanning"
+            },
+            "data_type": "STRING",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         },
         {
-            "shortName": "hoveddiagnose",
-            "name": "Hoveddiagnose",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "2578",
-            "comment": "En diagnose som gis for den tilstanden som er hoved\u00e5rsaken til at pasienten trenger behandling eller unders\u00f8kelse p\u00e5 sykehus",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
+            "short_name": "hoveddiagnose",
+            "name": {
+                "en": "Primary diagnosis",
+                "nn": "",
+                "nb": "Hoveddiagnose"
+            },
+            "data_type": "STRING",
+            "variable_role": "MEASURE",
+            "definition_uri": null,
+            "direct_person_identifying": false,
+            "data_source": null,
+            "population_description": null,
+            "comment": null,
+            "temporality_type": null,
+            "measurement_unit": null,
             "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
+            "classification_uri": null,
+            "sentinel_value_uri": null,
+            "invalid_value_description": null,
+            "id": null,
+            "contains_data_from": null,
+            "contains_data_until": null
         }
     ]
 }
\ No newline at end of file


Test patch:
diff --git a/datadoc/tests/resources/person_data_v1__DOC_example_1.json b/datadoc/tests/resources/person_data_v1__DOC_example_1.json
deleted file mode 100644
index 20b6e1d9..00000000
--- a/datadoc/tests/resources/person_data_v1__DOC_example_1.json
+++ /dev/null
@@ -1,163 +0,0 @@
-{
-    "dataSourcePath": "klargjorte_data/person_data_v1.parquet",
-    "shortName": "person_data_v1",
-    "name": [
-        {
-            "languageCode": "no",
-            "value": "Persondata"
-        }
-    ],
-    "dataSetState": "PROCESSED_DATA",
-    "description": [
-        {
-            "languageCode": "no",
-            "value": "Testdata personer for demo og test."
-        }
-    ],
-    "temporalityType": null,
-    "spatialCoverageDescription": [
-        {
-            "languageCode": "no",
-            "value": "Norge"
-        }
-    ],
-    "populationDescription": [],
-    "id": null,
-    "createdDate": "2022-03-30 12:58:16.605900",
-    "createdBy": "bnj@ssb.no",
-    "dataOwner": null,
-    "lastUpdatedDate": null,
-    "lastUpdatedBy": null,
-    "version": "1",
-    "administrativeStatus": "DRAFT",
-    "variables": [
-        {
-            "shortName": "pers_id",
-            "name": "F\u00f8dselsnummer",
-            "dataType": "STRING",
-            "variableRole": "IDENTIFIER",
-            "definitionUri": "26",
-            "comment": "Variabelen viser f\u00f8dselsnummer, det vil si f\u00f8dselsdag, -m\u00e5ned og -\u00e5r (6 siffer) og personnummer (5 siffer)",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "tidspunkt",
-            "name": "Tidspunkt",
-            "dataType": "DATETIME",
-            "variableRole": "START_TIME",
-            "definitionUri": null,
-            "comment": "M\u00e5lt 31.12. hvert \u00e5r",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "sivilstand",
-            "name": "Sivilstand",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "91",
-            "comment": "Variabelen viser en persons stilling i forhold til ekteskapslovgivningen",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "alm_inntekt",
-            "name": "Alminnelig inntekt",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "1438",
-            "comment": "Alminnelig inntekt er alle skattepliktige inntekter fratrukket skattemessige fradrag. Positiv alminnelig inntekt er grunnlaget for utligning av inntektsskatt.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "sykepenger",
-            "name": "Sykepenger",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "3366",
-            "comment": "Sykepenger fra folketrygden (utbetalt av NAV), som kompensasjon for bortfall av arbeidsinntekt for arbeidstakere i en sykdomsperiode . Dette inkluderer ikke arbeidsgiverperioden, der de f\u00f8rste 16 dagene av sykemeldingen dekkes av arbeidsgiver. Feriepenger av sykepenger er medregnet.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "ber_bruttoformue",
-            "name": "Beregnet bruttoformue",
-            "dataType": "INTEGER",
-            "variableRole": "MEASURE",
-            "definitionUri": "3327",
-            "comment": "Omfatter summen av beregnet realkapital og beregnet bruttofinanskapital.",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "fullf_utdanning",
-            "name": "Fullf\u00f8rt utdanning",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "3242",
-            "comment": "Fullf\u00f8rt utdanning betyr at eleven/studenten har f\u00e5tt vitnem\u00e5l/kursbevis eller p\u00e5 annen m\u00e5te fyller de krav som stilles til gjennomg\u00e5tt og best\u00e5tt utdanning",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        },
-        {
-            "shortName": "hoveddiagnose",
-            "name": "Hoveddiagnose",
-            "dataType": "STRING",
-            "variableRole": "MEASURE",
-            "definitionUri": "2578",
-            "comment": "En diagnose som gis for den tilstanden som er hoved\u00e5rsaken til at pasienten trenger behandling eller unders\u00f8kelse p\u00e5 sykehus",
-            "populationDescription": null,
-            "temporalityType": null,
-            "mesurementType": null,
-            "measurementUnit": null,
-            "format": null,
-            "sentinelAndMissingValueUri": [],
-            "unitType": null,
-            "foreignKeyType": null
-        }
-    ]
-}
\ No newline at end of file
diff --git a/datadoc/tests/test_callbacks.py b/datadoc/tests/test_callbacks.py
index 731ca0de..e1215d22 100644
--- a/datadoc/tests/test_callbacks.py
+++ b/datadoc/tests/test_callbacks.py
@@ -1,11 +1,13 @@
 from copy import deepcopy
+import random
 
 from datadoc_model.Enums import DatasetState, VariableRole
-from datadoc_model.Model import LanguageStrings
+from datadoc_model.Model import LanguageStrings, DataDocDataSet, DataDocVariable
+from datadoc.frontend.fields.DisplayVariables import VariableIdentifiers
 from datadoc.tests.utils import TEST_PARQUET_FILEPATH
 import datadoc.state as state
-from datadoc.DataDocMetadata import DataDocMetadata
-from datadoc import Callbacks
+from datadoc.backend.DataDocMetadata import DataDocMetadata
+from datadoc.frontend.callbacks import Callbacks
 from datadoc.Enums import SupportedLanguages
 
 
@@ -31,6 +33,12 @@
     },
 ]
 
+ENGLISH_NAME = "English Name"
+BOKMÅL_NAME = "Bokmål Name"
+NYNORSK_NAME = "Nynorsk Name"
+
+LANGUAGE_OBJECT = LanguageStrings(en=ENGLISH_NAME, nb=BOKMÅL_NAME)
+
 
 def test_accept_variable_metadata_input_no_change_in_data():
     state.metadata = DataDocMetadata(TEST_PARQUET_FILEPATH)
@@ -82,10 +90,8 @@ def test_accept_dataset_metadata_input_incorrect_data_type():
 
 
 def test_change_language():
-    ENGLISH_NAME = "English Name"
-    BOKMÅL_NAME = "Bokmål Name"
     state.metadata = DataDocMetadata(TEST_PARQUET_FILEPATH)
-    state.metadata.meta.dataset.name = LanguageStrings(en=ENGLISH_NAME, nb=BOKMÅL_NAME)
+    state.metadata.meta.dataset.name = LANGUAGE_OBJECT
     output = Callbacks.update_dataset_metadata_language(SupportedLanguages.NORSK_BOKMÅL)
     assert ENGLISH_NAME not in output
     assert BOKMÅL_NAME in output
@@ -94,16 +100,47 @@ def test_change_language():
     assert BOKMÅL_NAME not in output
 
 
-def test_nonetype_value_for_language_string():
-    ENGLISH_NAME = "English Name"
-    BOKMÅL_NAME = "Bokmål Name"
+def test_find_existing_language_string_no_existing_strings():
+    dataset_metadata = DataDocDataSet()
+    state.current_metadata_language = SupportedLanguages.NORSK_BOKMÅL
+    language_strings = Callbacks.find_existing_language_string(
+        dataset_metadata, BOKMÅL_NAME, "name"
+    )
+    assert language_strings == LanguageStrings(nb=BOKMÅL_NAME)
+
+
+def test_find_existing_language_string_pre_existing_strings():
+    dataset_metadata = DataDocDataSet()
+    dataset_metadata.name = LANGUAGE_OBJECT
+    state.current_metadata_language = SupportedLanguages.NORSK_NYNORSK
+    language_strings = Callbacks.find_existing_language_string(
+        dataset_metadata, NYNORSK_NAME, "name"
+    )
+    assert language_strings == LanguageStrings(
+        nb=BOKMÅL_NAME, en=ENGLISH_NAME, nn=NYNORSK_NAME
+    )
+
+
+def test_update_variable_table_language():
     state.metadata = DataDocMetadata(TEST_PARQUET_FILEPATH)
-    state.metadata.variables_lookup["pers_id"].name = LanguageStrings(
-        en=ENGLISH_NAME, nb=BOKMÅL_NAME
+    test_variable = random.choice([v.short_name for v in state.metadata.meta.variables])
+    state.metadata.variables_lookup[test_variable].name = LANGUAGE_OBJECT
+    output = Callbacks.update_variable_table_language(
+        [v.dict() for v in state.metadata.meta.variables],
+        SupportedLanguages.NORSK_BOKMÅL,
     )
+    name = [
+        d[VariableIdentifiers.NAME.value]
+        for d in output[0]
+        if d[VariableIdentifiers.SHORT_NAME.value] == test_variable
+    ][0]
+    assert name == BOKMÅL_NAME
+
+
+def test_nonetype_value_for_language_string():
+    state.metadata = DataDocMetadata(TEST_PARQUET_FILEPATH)
+    state.metadata.variables_lookup["pers_id"].name = LANGUAGE_OBJECT
     state.current_metadata_language = SupportedLanguages.NORSK_NYNORSK
     Callbacks.accept_variable_metadata_input(DATA_NONETYPE, DATA_ORIGINAL)
 
-    assert state.metadata.variables_lookup["pers_id"].name == LanguageStrings(
-        en=ENGLISH_NAME, nb=BOKMÅL_NAME
-    )
+    assert state.metadata.variables_lookup["pers_id"].name == LANGUAGE_OBJECT
diff --git a/datadoc/tests/test_datadoc_metadata.py b/datadoc/tests/test_datadoc_metadata.py
index ce980df5..f6c61437 100644
--- a/datadoc/tests/test_datadoc_metadata.py
+++ b/datadoc/tests/test_datadoc_metadata.py
@@ -6,7 +6,7 @@
 from datadoc_model import Enums
 
 from datadoc_model.Enums import DatasetState
-from datadoc import DataDocMetadata
+from datadoc.backend.DataDocMetadata import DataDocMetadata
 from datadoc_model.Model import DataDocDataSet, DataDocVariable, MetadataDocument
 from .utils import (
     TEST_EXISTING_METADATA_FILE_NAME,
@@ -16,6 +16,11 @@
 )
 
 
+@pytest.fixture
+def metadata():
+    yield DataDocMetadata(TEST_PARQUET_FILEPATH)
+
+
 @pytest.fixture
 def make_paths():
     split_path = list(PurePath(TEST_PARQUET_FILEPATH).parts)
@@ -37,33 +42,34 @@ def make_paths():
     return test_data
 
 
-def test_get_dataset_state(make_paths):
-    metadata = DataDocMetadata.DataDocMetadata(TEST_PARQUET_FILEPATH)
-    for path, expected_result in make_paths:
-        actual_state = metadata.get_dataset_state(path)
-        assert actual_state == expected_result
-
-
-def test_get_dataset_state_no_parameter_supplied():
-    metadata = DataDocMetadata.DataDocMetadata(TEST_PARQUET_FILEPATH)
-    assert metadata.get_dataset_state() is None
+@pytest.fixture
+def remove_document_file() -> None:
+    yield None  # Dummy value, No need to return anything in particular here
+    os.remove(os.path.join(TEST_RESOURCES_DIRECTORY, TEST_EXISTING_METADATA_FILE_NAME))
 
 
 @pytest.fixture
 def existing_metadata_file():
     # Setup by copying the file into the relevant directory
     shutil.copy(TEST_EXISTING_METADATA_FILEPATH, TEST_RESOURCES_DIRECTORY)
-    yield True  # Dummy value, No need to return anything in particular here
-    # Cleanup by deleting the file once we're done
-    os.remove(os.path.join(TEST_RESOURCES_DIRECTORY, TEST_EXISTING_METADATA_FILE_NAME))
+    yield None  # Dummy value, No need to return anything in particular here
 
 
-def test_existing_metadata_file(existing_metadata_file):
-    metadata = DataDocMetadata.DataDocMetadata(TEST_PARQUET_FILEPATH)
+def test_get_dataset_state(metadata, make_paths):
+    for path, expected_result in make_paths:
+        actual_state = metadata.get_dataset_state(path)
+        assert actual_state == expected_result
+
+
+def test_get_dataset_state_no_parameter_supplied(metadata):
+    assert metadata.get_dataset_state() is None
+
+
+def test_existing_metadata_file(existing_metadata_file, metadata, remove_document_file):
     assert metadata.meta.dataset.name.en == "successfully_read_existing_file"
 
 
-def test_metadata_document_percent_complete():
+def test_metadata_document_percent_complete(metadata):
     dataset = DataDocDataSet(dataset_state=Enums.DatasetState.OUTPUT_DATA)
     variable_1 = DataDocVariable(data_type=Enums.Datatype.BOOLEAN)
     variable_2 = DataDocVariable(data_type=Enums.Datatype.INTEGER)
@@ -73,7 +79,21 @@ def test_metadata_document_percent_complete():
         dataset=dataset,
         variables=[variable_1, variable_2],
     )
-    metadata = DataDocMetadata.DataDocMetadata(TEST_PARQUET_FILEPATH)
     metadata.meta = document
 
     assert metadata.percent_complete == 11
+
+
+def test_get_dataset_version(metadata):
+    assert metadata.get_dataset_version(metadata.dataset_stem) == "1"
+
+
+def test_get_dataset_version_unknown(metadata):
+    assert metadata.get_dataset_version("person_data.parquet") is None
+
+
+def test_write_metadata_document(metadata, remove_document_file):
+    metadata.write_metadata_document()
+    assert os.path.exists(
+        os.path.join(TEST_RESOURCES_DIRECTORY, TEST_EXISTING_METADATA_FILE_NAME)
+    )
diff --git a/datadoc/tests/test_dataset_reader.py b/datadoc/tests/test_dataset_reader.py
index 44cf68da..7b150ae2 100644
--- a/datadoc/tests/test_dataset_reader.py
+++ b/datadoc/tests/test_dataset_reader.py
@@ -5,7 +5,7 @@
 from datadoc_model.Enums import Datatype
 from datadoc import state
 from datadoc.Enums import SupportedLanguages
-from datadoc.DatasetReader import (
+from datadoc.backend.DatasetReader import (
     KNOWN_BOOLEAN_TYPES,
     KNOWN_DATETIME_TYPES,
     KNOWN_FLOAT_TYPES,
@@ -68,6 +68,11 @@ def test_get_fields_unknown_file_type():
         DatasetReader.for_file("my_dataset.csv").get_fields()
 
 
+def test_get_fields_no_extension_provided():
+    with raises(FileNotFoundError):
+        DatasetReader.for_file("my_dataset").get_fields()
+
+
 def test_transform_datatype_unknown_type():
     expected = None
     input_data = "definitely not a known data type"
diff --git a/datadoc/tests/test_model.py b/datadoc/tests/test_model.py
index 8505c83b..7ad6b821 100644
--- a/datadoc/tests/test_model.py
+++ b/datadoc/tests/test_model.py
@@ -1,5 +1,8 @@
-from datadoc.frontend.DisplayDataset import DISPLAY_DATASET, DatasetIdentifiers
-from datadoc.frontend.DisplayVariables import DISPLAY_VARIABLES, VariableIdentifiers
+from datadoc.frontend.fields.DisplayDataset import DISPLAY_DATASET, DatasetIdentifiers
+from datadoc.frontend.fields.DisplayVariables import (
+    DISPLAY_VARIABLES,
+    VariableIdentifiers,
+)
 from datadoc_model.Model import (
     DataDocDataSet,
     DataDocVariable,
diff --git a/datadoc/tests/test_smoke.py b/datadoc/tests/test_smoke.py
new file mode 100644
index 00000000..c904994a
--- /dev/null
+++ b/datadoc/tests/test_smoke.py
@@ -0,0 +1,13 @@
+from dash import Dash
+
+from datadoc.app import build_app
+from datadoc.backend.DataDocMetadata import DataDocMetadata
+import datadoc.state as state
+from .utils import TEST_PARQUET_FILEPATH
+
+
+def test_build_app():
+    state.metadata = DataDocMetadata(TEST_PARQUET_FILEPATH)
+    app = build_app(Dash)
+    assert app.config["name"] == "Datadoc"
+    assert len(app.callback_map.items()) > 0
diff --git a/datadoc/tests/test_utils.py b/datadoc/tests/test_utils.py
new file mode 100644
index 00000000..8ec675ac
--- /dev/null
+++ b/datadoc/tests/test_utils.py
@@ -0,0 +1,19 @@
+import sys
+from datadoc_model.Model import DataDocVariable, LanguageStrings
+from datadoc.Enums import SupportedLanguages
+from datadoc.utils import calculate_percentage, get_display_values, running_in_notebook
+from datadoc.tests.test_callbacks import LANGUAGE_OBJECT, BOKMÅL_NAME
+
+
+def test_not_running_in_notebook():
+    assert not running_in_notebook()
+
+
+def test_calculate_percentage():
+    assert calculate_percentage(1, 3) == 33
+
+
+def test_get_display_values():
+    variable = DataDocVariable(name=LANGUAGE_OBJECT)
+    values = get_display_values(variable, SupportedLanguages.NORSK_BOKMÅL)
+    assert values["name"] == BOKMÅL_NAME