Писать с нуля и поддерживать веб-приложения, тем более для бизнеса – сложная и муторная задача. При этом готовые решения часто бывает сложно подстроить под свои нужды. Возьмём таск-менеджеры: джира медленная, дорогая и часто слишком сложная, Trello слишком простой, а персональные таск-менеджеры не дают нужного взаимодействия с командой.

Мы написали свой low-code конструктор, на котором наши партнеры собирают CRM и ERP-решения для бизнеса, и на котором создавать и дорабатывать решения могут даже студенты. Давайте попробуем создать на нём простой, но почти полноценный и при этом расширяемый продукт, выходящий за рамки таск-менеджера из учебника по очередному фреймворку.

TL;DR — вот что у нас получится к концу статьи (и 3 часа работы)

Интерфейс получившегося таск-трекера
Интерфейс получившегося таск-трекера

Что хотим от таск-трекера

На первом этапе от нашей разработки много не нужно:

  • Он должен быть многопользовательским — мы хотим вести таски всей команды.

  • У задач может быть только один ответственный. Почему один? Для простоты, а если понадобится сделать несколько, то это реализуется дополнительной таблицей-связью и небольшими изменениями в собранных пользовательских представлениях.

  • Задачи можно представить в виде канбан-доски, обычной таблицы или дерева — смотря как кому будет удобнее.

  • Для каждой задачи есть простой лог изменений статуса и возможность оставлять комментарии. Без системы версий, предыдущих историй и тому подобного.

  • Дашборд с парой графиков на всякий случай. Кажется, что это тоже нужно.

Общая мысль — для начала будем собирать что-то максимально простое. Все следующие хотелки если и появятся, то уже в процессе использования системы, а докрутить их — обычно дело на 15 минут.

Создаем интерфейс

В нашем low-code конструкторе пользовательские представления создаются c помощью SQL-подобного языка FunQL. По сути в нём к обычным SQL-запросам добавляются атрибуты для настройки пользовательского интерфейса и немного синтаксического сахара — например, стрелка для обращения к связанным записям без JOIN. Внутри конструктора такой запрос оптимизируется и транспилируется в SQL, и к нему автоматически применяются права доступа конкретного пользователя – почти как ROW LEVEL SECURITY в традиционных БД, но с некоторыми расширениями. Сами представления похожи на функции — принимают аргументы и возвращают результат запроса.

Что будем создавать в первой версии системы:

  • Канбан-доска (с задачами)

  • Формы (для задачи и для человека-сотрудника)

  • Таблицы (с задачами и с людьми)

  • Дерево (с задачами)

  • Меню (страница со ссылками на все остальные представления)

  • Дашборды (графики по данным в базе)

Настраиваем канбан-доску

Наш выбор — это таск-менеджер с доской с карточками сгруппированными по статусам. Это самый популярный способ оценить объем работ и их состояние. Поэтому начнем с канбана.

Вот что должно у нас получиться после этого этапа. Выглядит уже почти как таск-менеджер :)
Вот что должно у нас получиться после этого этапа. Выглядит уже почти как таск-менеджер :)

Вот тут можно посмотреть страницу с канбан-доской в демке.
А тут посмотреть на FunQL запрос для создания этой страницы.

Теперь чуть подробнее по шагам, как мы к этому пришли.

С помощью FunQL опишем пользовательское представление для отображения данных о всех задачах в виде доски. Каждая запись из результата FunQL-запроса отображается как отдельная карточка. Для канбан-представления достаточно указать только поле, по которому карточки будут разбиваться на столбцы (в нашем случае задачи группируются по статусу, но настроить это можно как угодно).

По умолчанию все поля, указанные в блоке SELECT запроса, будут отображаться на карточке, а те, которые не нужны, можно скрыть атрибутом поля visible = false. Для каждого поля можно выбрать подпись, иконку и выделить значение цветом по определенной логике, например, как просроченные задачи на скриншоте выше.

Простой запрос для создания представления типа "канбан-доска"
SELECT
    /* User view type */
    @type = 'board',
    /* User view title */
    @title = 'Задачи',
    subject @{
        /* Material design icon */
        icon = 'subject'
    },
    status @{
        /* Group entries by this field */
        board_group = true,
        /* Do not display status field on the card */
        visible = false
    },
    start_date @{
        icon = 'date_range'
    },
    due_date @{
        icon = 'flag',
        cell_variant = CASE
            WHEN due_date < $$transaction_time
            THEN 'warning'
        END
    },
    priority @{
        icon = 'priority_high'
    },
    responsible_contact @{
        icon = 'person'
    },
    "order" @{
        /* Use the value from the "order" field
        as a sorting number for all entries */
        board_order = true,
        visible = false
    }
FROM 
    pm.tasks

В таком канбане уже можно перемещать карточки, а система сама будет менять значения полей, которые отвечают за колонку карточки и порядок в ней. Канбан, как и другие представления, работает и в браузерах на мобильных устройствах.

После этого добавим несколько базовых фильтров, чтобы можно было делать выборку задач, не открывая код представления. Фильтры мы реализуем через аргументы в представлении, которые по умолчанию они будут равны NULL. Чаще всего задачи требуется фильтровать по ответственным, приоритету, статусу или сроку. Остальные фильтры можно добавить потом, если понадобится.

Добавляем к запросу аргументы представления и правильно обрабатываем их в блоке WHERE
{   /* Arguments of the user view */
    $responsible array(reference(base.people)) null @{
        caption = 'Ответственный',
        /* Available values in popup will be restricted
        with the results of the specified user view */
        options_view = &base.ref_base_people_view
    },
    $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{
        caption = 'Статус',
        text = array mapping
           WHEN 'backlog' THEN 'Идеи'
           WHEN 'new' THEN 'Новое'
           WHEN 'in_progress' THEN 'В работе'
           WHEN 'done' THEN 'Завершено'
       END,
    },
    $priority array(enum('low', 'medium', 'high', 'urgent')) null @{
        caption = 'Приоритет',
        text = array mapping
           WHEN 'low' THEN 'Низкий'
           WHEN 'medium' THEN 'Средний'
           WHEN 'high' THEN 'Высокий'
           WHEN 'urgent' THEN 'Критичный'
       END
    },
    $is_archived bool null @{
        caption = 'Архив'
    },
    /* другие аргументы */
}:
SELECT /* ... */
 FROM pm.tasks
WHERE ($responsible IS NULL
       OR responsible_contact = ANY($responsible))
  AND ($status IS NULL
       OR status = ANY($status))
  AND ($priority IS NULL
       OR priority = ANY($priority))
  AND ($is_archived IS NULL
       OR is_archived = $is_archived)
		

Добавим сверху несколько кнопок для быстрого применения фильтров к представлению — “Мои” для отображения задач текущего пользователя, “Просрочено” — для отображения задач, срок которых уже наступил, “Не завершено” — для всех незакрытых задач. Фактически эти кнопки ведут на тот же самый юзервью, но передают в него разные аргументы.

Добавляем к запросу атрибут @buttons и задаем в нём настройки для кнопок
@buttons = [
   {
        /* Caption of the button */
        caption: 'Статус',
        /* Material design icon code */
        icon: 'filter_alt',
        /* Show button at the top right corner on PCs */
        display: 'desktop',
        buttons: [
            {
                caption: 'Просрочено',
                icon: 'flag',
                /* Reference to the user view */
                ref: &pm.tasks_board,
                /* New values for arguments */
                args: {
                    status: ['backlog', 'new', 'in_progress'],
                    due_date_to: $$transaction_time,
                    is_archived: false,
                    responsible: $responsible,
                    parent_task: $parent_task
                },
                target: 'top'
            },
            {
                caption: 'Не завершено',
                icon: 'notifications',
                ref: &pm.tasks_board,
                args: {
                    status: ['new', 'in_progress'],
                    is_archived: false,
                    responsible: $responsible,
                    parent_task: $parent_task
                },
                target: 'top'
            },
            {
                caption: 'Все',
                icon: 'filter_list_alt',
                ref: &pm.tasks_board,
                args: {
                    is_archived: false,
                    responsible: $responsible,
                    parent_task: $parent_task
                },
                target: 'top'
            }
        ]
    }
]

В финале добавим ссылки на создание новой записи в отдельном окне прямо из канбан-колонки и укажем какую форму открыть при клике на карточку.

Добавляем ссылки на другие представления
/* Reference to the user view opened by clicking the "Open entry in modal" button */
@row_link = &pm.task_form, 
/* Set the user view used for creating new entries */
@card_create_view = {
    /* Reference to the user view */
    ref: &pm.task_form,
    /* Default values for the entry being created */
    default_values: {
        responsible_contact: $responsible,
        parent_task: $parent_task
    }
}

На этом с канбаном вроде всё.

Итоговый запрос для нашего канбана получился таким
{   
    /* Arguments of the user view */ 
    $responsible array(reference(base.people)) null @{
        caption = 'Ответственный',
        /* Available values in popup will be restricted 
        with the results of the specified user view */
        options_view = &base.ref_base_people_view    
    },
    $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{
        caption = 'Статус',
        text = array mapping
           WHEN 'backlog' THEN 'Идеи'
           WHEN 'new' THEN 'Новое'
           WHEN 'in_progress' THEN 'В работе'
           WHEN 'done' THEN 'Завершено'
       END
    },
    $priority array(enum('low', 'medium', 'high', 'urgent')) null @{
        caption = 'Приоритет',
        text = array mapping
           WHEN 'low' THEN 'Низкий'
           WHEN 'medium' THEN 'Средний'
           WHEN 'high' THEN 'Высокий'
           WHEN 'urgent' THEN 'Критичный'
       END
    },
    $parent_task reference(pm.tasks) null @{
        caption = 'Надзадача'
    },
    $start_date_from datetime null @{
        caption = 'Дата начала с'
    },
    $start_date_to datetime null @{
        caption = 'Дата начала до'
    },
    $due_date_from datetime null @{
        caption = 'Срок с'
    },
    $due_date_to datetime null @{
        caption = 'Срок до'
    },
    $is_archived bool null @{
        caption = 'Архив'
    }
}:
SELECT
    /* User view type. See also: https://wiki.ozma.io/ru/docs/funapp/board */    
    @type = 'board', 
    /* User view title */
    @title = 'Задачи',
    /* "Filters" panel is hidden by default */
    @show_argument_editor = false,  
    /* "Filters" button will be showed on top of the board */
    @show_argument_button = true,   
    /* Reference to the user view opened by clicking the "Open entry in modal" button */
    @row_link = &pm.task_form,  
    /* Set the user view used for creating new entries */
    @card_create_view = { 
        /* Reference to the user view */
        ref: &pm.task_form,
        /* Default values for the entry being created */
        default_values: {                              
            responsible_contact: $responsible,
            parent_task: $parent_task
        }
    },
    /* Specifications of the interface buttons */
    @buttons = [ /* ... */ ],

    subject @{
        /* Material design icon */
        icon = 'subject' 
    },
    status @{
        /* Group entries by this field */
        board_group = true,   
        /* Do not display status field on the card */
        visible = false,  
    },
    start_date @{
        icon = 'date_range',
    },
    due_date @{
        icon = 'flag'
    },
    priority as priority_raw @{
        /* Display the icon specified in the default attributes for the "priority" field */
       icon = priority.@icon,
    },
    responsible_contact @{
        icon = 'person'
    },
    parent_task @{
        icon = 'account_tree'
    },
    "order" @{
         /* Use the value from the "order" field as a sorting number for all entries */
        board_order = true,  
        visible = false,
    },
     FROM pm.tasks
    WHERE ($is_archived IS NULL OR is_archived = $is_archived)
      AND ($responsible IS NULL OR responsible_contact = ANY($responsible))
      AND (($parent_task IS NULL AND parent_task IS NULL) OR parent_task = $parent_task)
      AND ($status IS NULL OR status = ANY($status))
      AND ($priority IS NULL OR priority = ANY($priority))
      AND ($start_date_to IS NULL OR start_date <= $start_date_to)
      AND ($start_date_from IS NULL OR start_date >= $start_date_from)
      AND ($due_date_to IS NULL OR due_date <= $due_date_to)
      AND ($due_date_from IS NULL OR due_date <= $due_date_to)
 ORDER BY "order",
          is_archived,
          completed_datetime DESC NULLS FIRST,
          due_date NULLS LAST,
          id

Всё написанное после @ — это атрибуты для настройки отображения.  У нас есть два типа атрибутов —  атрибуты представления (юзервью), например,  @title, @row_link и прочие, и атрибуты колонок — @{ visible = false }

Атрибуты для колонок могут задаваться не только в запросе, но и в настройках по умолчанию для каждой колонки (таблица public.default_attributes). Так мы можем не указывать в каждой таблице названия полей, цветовые выделения и прочее. Ещё есть атрибуты аргументов – через них мы задаем названия у фильтров { $is_archived bool null @{ caption = 'Архив' }}

Пилим формы

Форма задачи с большим блоком для заметок, комментами и подзадачами
Форма задачи с большим блоком для заметок, комментами и подзадачами

Так выглядит форма задачи в демо-инстансе. А это её FunQL запрос.

Делаем всё по очереди:

  1. Создадим карточку (форму) задачи. FunQL-запрос для формы почти такой же, как запрос для канбана или таблицы за одним исключением — он возвращает одну запись по id, который передается форме в качестве аргумента.

  2. Разобьем форму на блоки, расположим поля и другие контролы по блокам так, чтобы и на мобилке, и на ПК было удобно работать с задачей.

  3. Добавим на форму таймлайн с комментариями — если всё правильно настроить (написать триггеры), то при изменении конкретной задачи в таймлайне будут отображаться ивенты об изменениях.

  4. Ниже встроим на форму доску с подзадачами этой задачи.

  5. Кнопка “Архивировать”/”Разархивировать” - сейчас меняет статус значения поля "Архив" на  true или false, но позже можно будет прикрутить на них более сложную логику.

В итоге получится какой-то такой запрос для формы
{
    $id reference(pm.tasks)
}:
SELECT 
    /* User view type */
    @type = 'form',
    /* User view title */
    @title = $id=>__main,
    /* Form block sizes. This form has three blocks - two first row blocks with the ratio of 7 to 5, and the third block occupying the second row */
    @block_sizes = array[
        7, 5,
        12
    ],
    @buttons = [
        {
            caption: 'Архивировать запись',
            /* Show button at the top right corner on PCs */
            display: 'desktop',
            /* Show button only if the record is not archived */
            visible: NOT $id=>is_archived,
            /* Set material design icon */
            icon: 'archive',
            /* Color variant for the button */
            variant: 'outline-danger',
            action: { 
                schema: 'user', 
                name: 'archive_record'
            },
            args: { 
                entity: { 
                    schema: 'pm', 
                    name: 'tasks'
                }, 
                is_archived: true, 
                id: $id 
            }
        },
        {
            caption: 'Разархивировать запись',
            display: 'desktop',
            visible: $id=>is_archived,
            icon: 'archive',
            variant: 'outline-success',
            action: { 
                schema: 'user', 
                name: 'archive_record'
            },
            args: { 
                entity: { 
                    schema: 'pm', 
                    name: 'tasks'
                }, 
                is_archived: false, 
                id: $id 
            }
        }
    ],
    subject @{
        /* Display input control in the first block */ 
        form_block = 0
    },
    description @{
        form_block = 0,
        /* Use multiline WYSIWYG editor */
        text_type = 'wysiwyg',
        /* Set control height to 400 px */
        control_height = 400
    },
    parent_task @{
        form_block = 0,
    },
    status @{
        form_block = 1
    },
    responsible_contact @{
        form_block = 1
    },
    priority @{
        form_block = 1
    },
    start_date @{
        form_block = 1,
        default_value = $$transaction_time
    },
    due_date @{
        form_block = 1
    },
    /* Arrange nested user views on the form */
    {
        /* Reference to the nested user view */
        ref: &pm.tasks_board,
        /* Arguments that will be passed to the nested user view */
        args: {
            parent_task: $id
        }
    } as subtasks @{
        /* Display user view control on the form  */
        control = 'user_view',
        /* Display user View control in the first block */
        form_block = 4,
        /* Caption for the nested User View. User view @title will be replaced with this caption */
        caption = 'Подзадачи'
    },
    {
        ref: &pm.notes_for_task_timeline,
        args: {
            id: $id
        }
    } as notes @{
        control = 'user_view',
        form_block = 1,
        caption = 'Комментарии'
    }
FROM 
    pm.tasks 
WHERE 
    id = $id 
FOR INSERT INTO 
    pm.tasks

В готовой форме можно создавать новые и редактировать существующие записи. Система связывает выбираемые данные с исходными записями, так что одного SELECT вполне достаточно для отображения, а для создания мы явно указываем желаемую сущность в конструкции FOR INSERT INTO.

Отображение задач в виде таблицы (куда же без нее)

Таблица задач
Таблица задач

Ссылка на таблицу с задачами.
Ссылка на код запроса, который эту таблицу создал.

Делаем всё так же, как и с канбаном — напишем FunQL-запрос для отображения данных, добавим несколько фильтров, добавим несколько кнопок с “сохраненными” фильтрами в верхнюю панель. В таблице еще будет не лишним настроить пагинацию для отображения по 25 записей на странице.

В финале еще добавим ссылки на создание новой записи прямо из таблицы или в отдельном окне, и ссылки на другие представления, где это необходимо.

FunQL-запрос для создания таблицы задач
{     
    /* Arguments of the user view */
    $is_archived bool null @{
        caption = 'Архив'
    },
    $responsible array(reference(base.people)) null @{
        caption = 'Ответственный',
        /* Available values in popup will be restricted with the results of the specified user view */
        options_view = &base.ref_base_people_view 
    },
    $parent_task reference(pm.tasks) null @{
        caption = 'Надзадача'
    },
    $status array(enum('backlog', 'new', 'in_progress', 'done')) null @{
        caption = 'Статус',
        text = array mapping
            WHEN 'backlog' THEN 'Идеи'
            WHEN 'new' THEN 'Новое'
            WHEN 'in_progress' THEN 'В работе'
            WHEN 'done' THEN 'Завершено'
        END,
    },
    $priority array(enum('low', 'medium', 'high', 'urgent')) null @{
        caption = 'Приоритет',
        text = array mapping
            WHEN 'low' THEN 'Низкий'
            WHEN 'medium' THEN 'Средний'
            WHEN 'high' THEN 'Высокий'
            WHEN 'urgent' THEN 'Критичный'
        END
    },
    $start_date_from datetime null @{
        caption = 'Дата начала с'
    },
    $start_date_to datetime null @{
        caption = 'Дата начала до'
    },
    $due_date_from datetime null @{
        caption = 'Срок с'
    },
    $due_date_to datetime null @{
        caption = 'Срок до'
    }
}:
SELECT
    /* User view type. See also: https://wiki.ozma.io/ru/docs/funapp/table */  
    /* This table can be displayed as a tree is case of all filters are null. More info about tree view: https://wiki.ozma.io/ru/docs/funapp/tree */ 
    @type = 'table',    
    /* User view title */
    @title = 'Задачи',
    /* "Filters" panel is hidden by default */
    @show_argument_editor = false,  
    /* "Filters" button will be showed on the top of the board */
    @show_argument_button = true,
    /* Set the user view used for creating new entries */
    @create_link = &pm.task_form,
    /* Reference to the user view opened by clicking the "Open entry in modal" button */
    @row_link = &pm.task_form,
    /* Disable ability to create new child record if parent task is not null and it was archived */
    @soft_disabled = $parent_task=>is_archived,  
    /* Set pagination options */
    @lazy_load = {
        pagination: { 
            /* Show 25 rows per page */
            per_page: 25
        }
    },
    /*  Specifications of the interface buttons */
    @buttons = [
        {
            /* Caption of the button */
            caption: 'Ответственный',
            /* Material design icon code */
            icon: 'people', 
            /* Show button at the top right corner on PCs */
            display: 'desktop',
            buttons: [
                {
                    caption: 'Я',
                    ref: &pm.tasks_table,
                    icon: 'person',
                    /* Apply a "light gray" color variant to the button if there is no person associated with the current user */
                    /* $$user_id returns ID of the current user */
                    variant: CASE WHEN (SELECT COUNT(1) FROM base.people WHERE user = $$user_id) = 0 THEN 'light' END, 
                    args: {
                        responsible: (
                            SELECT array_agg(id) 
                            FROM base.people 
                            WHERE user = $$user_id
                        ),
                        status: $status,
                        start_date_to: $start_date_to,
                        start_date_from: $start_date_from,
                        due_date_to: $due_date_to,
                        due_date_from: $due_date_from,
                        is_archived: $is_archived,
                        parent_task: $parent_task,
                        priority: $priority
                    },
                    /* Open link full screen */
                    target: 'top'
                },
                {
                    caption: 'Все',
                    ref: &pm.tasks_table,
                    icon: 'people',
                    args: {
                        responsible: null,
                        status: $status,
                        start_date_to: $start_date_to,
                        start_date_from: $start_date_from,
                        due_date_to: $due_date_to,
                        due_date_from: $due_date_from,
                        is_archived: $is_archived,
                        parent_task: $parent_task,
                        priority: $priority
                    },
                    target: 'top'
                }
            ]
        },
        {
            caption: 'Статус',
            icon: 'filter_alt',
            display: 'desktop',
            buttons: [
                {
                    caption: 'Просрочено',
                    icon: 'flag',
                    ref: &pm.tasks_table,
                    args: {
                        status: ['backlog', 'new', 'in_progress'],
                        due_date_to: $$transaction_time,
                        is_archived: false,
                        responsible: $responsible,
                        parent_task: $parent_task
                    },
                    target: 'top'
                },
                {
                    caption: 'Не завершено',
                    icon: 'notifications',
                    ref: &pm.tasks_table,
                    args: {
                        status: ['new', 'in_progress'],
                        is_archived: false,
                        responsible: $responsible,
                        parent_task: $parent_task
                    },
                    target: 'top'
                },
                {
                    caption: 'Завершено',
                    icon: 'done_all',
                    ref: &pm.tasks_table,
                    args: {
                        status: ['done'],
                        is_archived: false,
                        responsible: $responsible,
                        parent_task: $parent_task
                    },
                    target: 'top'
                },
                {
                    caption: 'Все',
                    icon: 'filter_list_alt',
                    ref: &pm.tasks_table,
                    args: {
                        is_archived: false,
                        responsible: $responsible,
                        parent_task: $parent_task
                    },
                    target: 'top'
                }, 
                {
                    caption: 'Архив',
                    icon: 'delete',
                    ref: &pm.tasks_table,
                    args: {
                        is_archived: true,
                        responsible: $responsible,
                        parent_task: $parent_task
                    },
                    target: 'top'
                }
            ]
        },
        {   
            /* Open board with tasks instead of tasks table */   
            caption: 'Доска',  
            tooltip: 'Отображать задачи в виде канбан-доски',
            icon: 'sticky_note_2',
            variant: 'dark', 
            display: 'desktop',
            ref: &pm.tasks_board,  
            /* Use filters from tasks table for tasks board */
            args: {
                is_archived: $is_archived,
                parent_task: $parent_task,
                status: $status,
                priority: $priority,
                start_date_from: $start_date_from,
                start_date_to: $start_date_to,
                due_date_from: $due_date_from,
                due_date_to: $due_date_to,
                responsible: $responsible
            },   
            target: 'top'
        },
    ],

    subject @{
        /* Set column width to 350 px */
        column_width = 350,  
    },
    status @{
        column_width = 100
    },
    priority @{
        column_width = 100
    },
    start_date @{
        column_width = 100,
        default_value = $$transaction_time /* $$transaction_time returns current timestamp */
    },
    due_date @{
        column_width = 100
    },
    description @{
        column_width = 300
    },
    responsible_contact @{
        column_width = 150
    },
    parent_task @{
        /* Set $parent_task argument value as a default value for "parent_task" field */
        default_value = $parent_task, 
        /* Display column "parent_task" only if $parent_task argument value is null */
        visible = $parent_task IS NULL
    },
    completed_datetime @{
        column_width = 125,
        visible = ($status = array['done'])
    },
    completed_person @{
        column_width = 125,
        visible = ($status = array['done'])
    }
    FROM pm.tasks
   WHERE ($is_archived IS NULL OR is_archived = $is_archived)
     AND ($responsible IS NULL OR responsible_contact = ANY($responsible))
     AND ($parent_task IS NULL OR parent_task = $parent_task)
     AND ($status IS NULL OR status = ANY($status))
     AND ($priority IS NULL OR priority = ANY($priority))
     AND ($start_date_to IS NULL OR start_date <= $start_date_to)
     AND ($start_date_from IS NULL OR start_date >= $start_date_from)
     AND ($due_date_to IS NULL OR due_date <= $due_date_to)
     AND ($due_date_from IS NULL OR due_date <= $due_date_to)
ORDER BY is_archived,
         status.@order_number, /* Return an attribute "order_numer" value for the "status" column field */
         completed_datetime DESC NULLS FIRST,
         start_date NULLS LAST,
         id
 /* Set the main entity */
FOR INSERT INTO pm.tasks

Одного SELECT по-прежнему достаточно для редактирования, удаления и добавления записей. Работать будет даже с JOIN и UNION в запросах.

Деревья - простая вложенная структура тасков

Древовидная таблица для отображения задач и их подзадач
Древовидная таблица для отображения задач и их подзадач

Отображение задач в виде дерева можно посмотреть тут.
FunQL запрос у дерева и таблицы один и тот же. Он тут.

Древовидная таблица — это обычная таблица, которая отображает родительские и дочерние задачи в виде списка с любым уровнем вложенности. Проще говоря, это иерархия, подзадачи и подподзадачи (и сколько угодно ещё уровней вложенности).

Сделать из таблицы задач таблицу с древовидной структурой задач и подзадач можно с помощью одного дополнительного атрибута для поля parent_task @{ tree_parent_ids = true }. Тогда фронтенд по умолчанию будет отрисовывать все задачи в виде дерева, ориентируясь по записям, связанным через колонку. 

По умолчанию дерево строится от корневых записей, где parent_task = null. Чтобы построить дерево от произвольной записи, нужно изменить запрос, добавив в него рекурсию.

Просто добавим этот атрибут в код таблицы - зачем пилить два разных представления, если можно обойтись одним. Только оставим условие, чтобы дерево отключалось при применении фильтров.

Меню - точка входа и быстрый доступ ко всем функциям

Фактически меню —  это просто набор ссылок, внешних или на другие представления. 

После логина пользователь попадает на страницу /user/main , поэтому мы сделали на этой странице главное меню — ссылки на представления для быстрого доступа.

В меню мы оставили минимум кнопок
В меню мы оставили минимум кнопок

Посмотреть, как выглядит главное меню можно в демо-инстансе.
FunQL запрос для создания меню тоже есть.

Чтобы сделать такое меню, создадим ссылки на задачи всех пользователей и на задачи текущего пользователями в виде доски и таблицы. После этого добавим ссылку на меню администратора и ссылку на небольшой дашборд со списком сотрудников, чтобы можно было быстро его отредактировать.

Код представления меню — это тоже FunQL-запрос:
SELECT
   /* User view type.See also: https://wiki.ozma.io/ru/docs/funapp/menu */
   @type = 'menu',
   /* Menu title */
   @title = 'Главное меню',
   @menu_centered = true,
   menu.blocks
FROM (
   VALUES ([
       {
           /* Menu block name */
           name: 'Все задачи', 
           /* Block width size = 3/12 */
           size: 3,
           /* Block content */
           content: [{
               /* Caption for the link */
               name: 'Отчеты',
               /* Reference to the linked user view */
               ref: &pm.dashboard,
               /* Icon for the link */
               icon: 'monitor_heart'
           },
           {
               name: 'Доска: Все задачи',
               ref: &pm.tasks_board,
               /* Arguments passed to the user view */
               args: {
                   is_archived: false,
               },
               icon: 'sticky_note_2',
           },
           {
               name: 'Таблица: Все задачи',
               ref: &pm.tasks_table,
               args: {
                   is_archived: false,
               },
               icon: 'table_chart',
           }]
       },
       {
           name: 'Мои задачи',
           size: 3,
           content: [{
               name: 'Доска: Мои задачи',
               ref: &pm.tasks_board,
               args: {
                   is_archived: false,
                   responsible: (SELECT array_agg(id) FROM base.people WHERE user = $$user_id) /* $$user_id returns ID of the current user */
               },
               icon: 'sticky_note_2',
           },
           {
               name: 'Таблица: Мои задачи',
               ref: &pm.tasks_table,
               args: {
                   is_archived: false,
                   responsible: (SELECT array_agg(id) FROM base.people WHERE user = $$user_id)
               },
               icon: 'table_chart',
               badge: {
                   value: (SELECT COUNT(id) FROM pm.tasks WHERE responsible_contact = ANY((SELECT id FROM base.people WHERE user = $$user_id))),
                   variant: 'danger'
               }
           }]
       },
       {
           name: 'Другое',
           size: 3,
           content: [{
               name: 'Люди',
               ref: &base.people_table,
               icon: 'person',
               badge: {
                   value: (SELECT COUNT(1) FROM base.people WHERE NOT is_archived),
                   variant: 'info'
               }
           },
           {
               name: 'Настройки',
               ref: &admin.main,
               icon: 'settings',
           }]
       },
   ])
) AS menu(blocks)

Дашборд - красивые графики, которые любят все

Аналитика, которую мы заслужили
Аналитика, которую мы заслужили

Для первой версии добавим 4 графика, которые могут пригодиться на старте:

  • донат-график с количеством открытых задач, чтобы видеть, сколько всего нужно сделать;

  • столбики с открытыми задачами по ответственным, чтобы смотреть кто это должен делать;

  • график активности закрытых задач, чтобы мониторить, что задачи всё-таки закрываются;

  • донат-график с завершенными задачами по приоритетам, чтобы оценить, насколько быстро их нужно было закрыть.

Остальные графики будем добавлять в процессе работы, как только они понадобятся. Но в самом начале нам достаточно этих четырёх.

Графики встраиваются через iframe – мы пишем страницу на HTML и передаем ей данные из базы. Это значит, что мы можем выбрать любую библиотеку для визуализации данных и подключить ее во вложенной странице, а веб-приложение будет передавать в iframe значение выбранного поля. Так можно делать виджеты для редактирования данных самостоятельно, например, с интеграцией в системы поиска адресов.

Все обрабатывается на фронте, поэтому важно получить из базы уже посчитанные данные, чтобы потом на js их надо было только отрисовать. Для простоты выбрали https://nvd3.org - набор компонентов, использующих https://d3js.org:

Создаем четыре формы с единственным контролом на каждой из них типа iframe. Немного дорабатываем код для графиков, собираем контролы с графиками на одну форму, чтобы получился простенький дашборд

Дашборд с демо-данными смотреть тут. А FunQL-запрос, который объединил четыре графика в один, тут.

FunQL-запрос, который собирает данные из базы и передает их в iframe.
Код iframe-markup-а для одного из графиков со скриншота выше.
Все остальные графики.

Допиливаем мелочи

Неупомянутыми остались еще несколько пользовательских представлений (как обычно, все запросы на языке FunQL):

Настраиваем автоматику

Триггеры и функции в ozma.io пишутся на JavaScript. Внутри доступен полный API для работы с базой, при этом вся операция происходит в одной транзакции. Триггеры работают так же, как и в других реляционных базах данных, и позволяют отменить операцию или изменить её аргументы.

Функции можно вызывать по кнопкам из интерфейса, или из внешнего сервиса по API — они также могут возвращать произвольные данные. Используя триггеры, функции и их интеграцию в интерфейс, можно создавать полноценные веб-приложения. К триггерам и действиям также применяются права доступа, но внутри можно выполнять и привилегированные запросы — так можно реализовывать служебные автоматические поля, если не хватает другого функционала.

Кнопки

Чтобы автоматизировать любой процесс, в том числе создание, изменение или удаление записей в правильном порядке, мы используем “экшены”. На языке нашего проекта, это функции на JS, которые можно дёрнуть из любого представления, передав ей произвольные данные.

В самом простом варианте таск-трекера нам не нужно автоматизировать сложные процессы (их у нас просто нет), но для примера можно рассмотреть экшн “архивации” записи. Он простой, и всего лишь меняет значение поля is_archived с true на false и обратно для всех записей, айдишники которых в него передали.

Пишем код экшна
export default async function archiveRecord(args) {
   const ids = args.ids ?? [args.id];

   if (!('is_archived' in args))
       throw 'Неправильно задано действие';

   for (const id of ids) {
       try {                                        // FunDB API function call
           await FunDB.updateEntity(                //  updateEntity: (
               args.entity,                         //     ref: IEntityRef, 
               id,                                  //     id: number,
               { is_archived: args.is_archived }    //     args: Record<string, unknown>
           );                                       //  ) => Promise<void>;
       } catch(e) {
           throw 'Неправильно задано действие';
       }
   }

   return { ok: true }
}

Теперь надо как-то вызывать экшн из интерфейса

Дописываем кнопки с вызовом экшна на форму задачи
/* pm.task_form */
@buttons = [ 
    {     
        caption: 'Архивировать запись',
        display: 'desktop',
        visible: NOT $id=>is_archived,
        icon: 'archive',
        variant: 'outline-danger',
        action: {
            schema: 'user',
            name: 'archive_record'
        },
        args: {
            entity: { schema: 'pm', name: 'tasks' },
            is_archived: true,
            id: $id
        }
    },
    {
        caption: 'Разархивировать запись',
        display: 'desktop',
        visible: $id=>is_archived,
        icon: 'archive',
        variant: 'outline-success',
        action: {
            schema: 'user',
            name: 'archive_record'
        },
        args: {
            entity: { schema: 'pm', name: 'tasks' },
            is_archived: false,
            id: $id
        }
    }
]

А потом то же самое - из таблицы задач

/* pm.tasks_table */
@buttons = [
    { /* Set is_archived = true for all selected records */ 
        caption: 'Архивировать выделенные записи', 
        /* Display button when records are selected with a check mark */
        display: 'selectionPanel',  
        /* Display button only if $is_archived argument value is false  */
        visible: NOT $is_archived,
        icon: 'archive',
        variant: 'danger',
        action: {
            schema: 'user',
            name: 'archive_record'
        },
        args: {
            entity: {
                schema: 'pm',
                name: 'tasks'
            },
            is_archived: true
        },
    },
    {
        caption: 'Разархивировать выделенные записи',
        display: 'selectionPanel',
        visible: $is_archived,
        icon: 'unarchive',
        variant: 'success',
        action: {
            schema: 'user',
            name: 'archive_record'
        },
        args: {
            entity: {
                schema: 'pm',
                name: 'tasks'
            },
            is_archived: false
        }
    }
]

Ссылка на код экшна user.archive_record.
Вызов этого экшна на форме задачи по нажатию на кнопку в верхней панели.
Вызов экшна из таблицы задач при выделении одной или нескольких записей галочками.

Триггеры (продолжение автоматизации)

Напишем триггер, который при изменении задачи будет добавлять в таблицу “notes_for_tasks” информацию об этом в удобном для понимания виде:

Триггер срабатывает на обновление статуса, приоритета или значения поля "Архив"
import { getPersonName } from 'admin/user_info.mjs';

export default async function insertNote(event, args) {
   const taskId = event.source.id;
   const now = new Date();
   const author = await getPersonName() ?? args.author;

   /* append status change event */
   if ('status' in args) {
       const msg = args.status
           ? `изменил(а) статус задачи на \"${args.status}\"`
           : `убрал(а) статус задачи`;

       await addNote(taskId, now, author, msg);
   }

   /* append priority change event */
   if ('priority' in args) {
       const msg = args.priority
           ? `изменил(а) приоритет задачи на \"${args.priority}\"`
           : `убрал(а) приоритет задачи`;

       await addNote(taskId, now, author, msg);
   }

   /* append archive change event */
   if ('is_archived' in args) {
       const msg = args.is_archived
           ? `переместил(а) задачу в Архив`
           : `вернул(а) задачу из Архива`;

       await addNote(taskId, now, author, msg);
   }

   return true;
}

/* add entity into "pm"."notes_for_tasks" table */
async function addNote(taskId, date, author, message) {
    await FunDB.insertEntity({
           schema: 'pm', name: 'notes_for_tasks'
       },
       {
           note_datetime: date,
           author: author,
           message: message,
           type: 'event',
           task: taskId
       }
   );
}

Теперь при изменении, например, статуса задачи с id = 101 с “Новое” на “В работе” сработает триггер, который вставит в таблицу notes_for_tasks новую запись:

{
       note_datetime:`2022-01-01 10:00:00`,
       author: `Петя`,
       message:`изменил(а) статус задачи на "В работе"`,
       type: 'event',
       task: 101
}

Эта информация и так фиксируется в логах, но чтобы всегда иметь эти данные вместе с записью и избежать медленных селектов по многомиллионным таблицам с логами — храним их структурированно для задач.

Аналогичные триггеры есть для “Дата создания”, “Кто создал” и “Дата изменения”, “Кто изменил”.

Ссылка на код триггера для вставки заметок/комментариев.
Другой пример - триггер, заполняющий поля "Дата завершения" и "Кто завершил" для задачи.
Список всех триггеров в демо-решении.

Про схему базы данных

Глядя на наш код может возникнуть вопрос — а куда мы вообще SELECT-ы пишем? Где база данных? А как создавать еще таблицы или изменять колонки? Вернемся немного в начало и пройдемся по структуре базы для нашего таск-трекера и тому, как её создать и настроить. 

Внутри ozma.io — реляционная база данных на основе PostgreSQL, поэтому для хранения данных, естественно, нужно заранее придумать структуру базы. Разрабатываем её с учетом возможного расширения и рисуем в Миро 

Схема базы данных в Миро — тоже простом инструменте, когда нужно быстро набросать что-то рабочее и одновременно понятное
Схема базы данных в Миро — тоже простом инструменте, когда нужно быстро набросать что-то рабочее и одновременно понятное

Чтобы впоследствии добавление новых модулей было менее болезненным, сразу делим сущности на несколько схем. Схемы, как и в других базах данных, это наборы таблиц и других сущностей, например:

  • в схеме pm — все сущности, связанные с задачами: Задачи (tasks) и Комментарии  (notes_for_tasks)

  • в схеме base будут Контакты (contacts) и унаследованные от них Люди (people) и Организации (organizations). Наследование позволяет ссылаться на контакт из других сущностей – внутри системы люди и организации находятся в одной таблице. 
    На самом деле “Организации” на первый взгляд кажутся бесполезными, мы их нигде не используем. Но когда-нибудь наступит момент, когда рядом с задачей нужно будет хранить ссылку на заказчика или провайдера или конкурента, а еще их телефоны и адреса. Лучше подстраховаться и заложить правильную архитектуру с самого начала, сэкономив своё драгоценное время впоследствии. 

  • схема public системная, и её менять нельзя, поэтому ей не уделяем много внимания. В ней нам интересна сущность Пользователи (users), т.к. тут хранится и настраивается список пользователей, у которых есть доступ к решению. 

Параллельно думаем над необходимой автоматикой:

  • Нужны будут несколько триггеров для заполнения полей, например “Дата создания”, “Кто создал”, “Дата изменения”, “Кто изменил” для таблиц задач (pm.tasks) и контактов (base.contacts)

  • И ещё нужны будут пара триггеров, чтобы вставлять кастомные ивенты в таблицу pm.notes_for_tasks

Готовим таблицы, поля и связи в БД

Заводим в админке конструктора схемы, сущности и поля, как нарисовали до этого  в схеме. Система автоматически создаст схемы, таблицы и поля в базе данных.

Форма сущности pm.tasks в админке решения
Форма сущности pm.tasks в админке решения

Вся информация о самой базе данных (о схемах, таблицах, полях, триггерах и остальном) лежит в схеме public.

Еще в самом начале развития конструктора мы создали набор пользовательских представлений для взаимодействия с этими сущностями и объединили их в схеме admin. Это не системная схема, в чистом новом решении её нет – весь интерфейс администрирования написан внутри самого конструктора. 

Актуальная версия лежит в открытом доступе, поэтому чаще админы либо пользуются ей, либо (гораздо реже) пишут свой интерфейс для редактирования сущностей, полей, триггеров и всего остального.

Можно посмотреть на список полей сущности tasks в демке.
Список всех схем решения можно посмотреть тут, а список сущностей тут.

Что не вошло в статью

Чтобы не растягивать статью до бесконечности, мы не стали описывать разграничение доступов, пользовательские роли и уведомления о задачах на почту и в телеграм. Если такое будет интересно — напишите в комментариях, расскажем, как это сделать самому.

Можно еще кстати выгружать локально себе весь код решения, править его в виде кода и разворачивать обратно в озму.

Как решение может расти дальше?

  • Добавим “Клиентов” и историю взаимодействия с ними;

  • Создадим сущность “Сделки” и воронки продаж;

  • Настроим представления для менеджеров и руководителей, а потом настроим их роли;

  • Напишем больше CRM-дашбордов.

Ещё соберём финансовый модуль. Но может и не соберём, если в работе будем справляться без него.

Что под капотом?

Платформа

Linux (NixOS/nixops), PostgreSQL

Бэкенд

F# (.NET Core), свой диалект SQL с компилятором в pgSQL

Фронтэнд

TypeScript/Vue.js

О нас

Озма придумана двумя студентами из бауманки Кириллом Маркиным и Николаем Амиантовым. Большие куски статьи и демо-пример написала Ирина Горохова. Спасибо Константину, Ренату Дарыбаеву, Даниилу и Любови за большой вклад в проект, Михаилу Полянину за помощь с текстом и Роману Белякову за иллюстрации.

Расскажите, чего вам не хватает в своем таск-трекере. Как думаете, будет удобно собирать на платформе такие решения под свои хотелки? Может ли быть полезна такая штука?

Комментарии (22)


  1. lisa_minaeva
    12.07.2022 11:34

    Кирилл, классная штука! Удачи в реализации!!)


    1. markinkirill Автор
      12.07.2022 11:35

      Спасибо)


  1. oracle_schwerpunkte
    12.07.2022 11:36
    +1

    Правильно ли я понимаю, что вся логика вручную пишется на FunQL + TypeScript ? Или есть визуальный редактор как например в Оракл Апекс, мышкой клик - клик и готово -) ?


    1. abbradar
      12.07.2022 11:40
      +1

      Всё верно. Визуальный редактор у нас в планах, но идея - дать максимальную гибкость за счёт простого, но кода.


  1. ewanmchorton
    12.07.2022 12:23

    Это очень прикольная идея. Круто, что можно на лоу коде такое реализовать. Всегда связку сервисов используем трелло, асана с гугл таблицами, а тут все в одном. Удобно)


  1. ollk0stin
    12.07.2022 13:24
    +1

    Выглядит очень прикольно и минималистично.

    Может, плохо читал, но есть ли защита от поломки древовидной структуры? Взял, связал таски в цикл, а они интерфейс сломают.

    Думаю, стоит добавить содержание со ссылками на разделы, потому что статья выглядит не только как ознакомление с продуктом, но и немного как туториал. Это помогло бы тем, кто пробует его использовать, быстро переходить к нужному разделу.


    1. irinagorokhova
      12.07.2022 14:22
      +2

      Мы потестили прямо на демо инстансе, и да, вы правы, если две таски зациклить, то фронт не понимает что происходит и вешает вкладку))
      Спасибо за баг! Садимся чинить

      Код, который всё сломает, если есть зацикленные записи 

      SELECT
      	subject,
      	parent_task @{ 
          	tree_parent_ids = true 
          }
      FROM
      	pm.tasks

      И результат ????


      Оглавление со ссылками на разделы добавим, спасибо за совет!


  1. dmitry_brazhenko
    12.07.2022 23:13
    +1

    Выглядит интересно, спасибо!

    Вопрос: есть Airtable и Notion, на которых можно собрать схожей сложности базы/формы/графики в несколько кликов. Какая цель у нового инструмента? Какую дополнительную пользу он может принести по сравнению с имеющимися?


    1. markinkirill Автор
      12.07.2022 23:23
      +1

      Да, Airtable и Notion можно и даже нужно использовать для этого, но как только количество записей в таблице будет больше 50к, то уже придется искать другое решение.

      У нас есть ребята, которые хранят десятки миллионов записей в озме и все летает. Надо только индексы правильно настроить.


      1. dmitry_brazhenko
        12.07.2022 23:30
        +1

        Спасибо!

        И еще вопрос вдогонку
        Как обеспечивается безопасность сервиса? Планируется ли аудит безопасности? И возможно ли использовать его как self-hosted решение?


        1. markinkirill Автор
          12.07.2022 23:41
          +1

          Как обеспечивается безопасность сервиса?

          Авторизация сейчас на Keycloak. Чуть позднее запустим багбаунти. Может быть утром чуть больше технических деталей Николай сможет добавить к этому ответу.

          Планируется ли аудит безопасности?

          Да, планируем, но позднее.

          возможно ли использовать его как self-hosted решение?

          Да, клиент может развернуть озму у себя на серверах и подключить свою авторизацию вместо нашего сервиса авторизации. Но обычно это решение для совсем крупных клиентов.


  1. Bobko
    13.07.2022 00:44

    На моменте с FunQL закатил глаза: "О боже, и тут без собственного языка не обошлись". То, что это собственный язык, к сожалению, тоже не сразу очевидно (ссылка с документацией на домен ozma.io намекает, но, кажется, лучше это явно проговорить). Как я понял собственный язык нужен в основном из-за атрибутов (кстати, пояснение, как аттрибут выглядит синтаксически, следует заметно позже нескольких примеров запросов).

    Для такой тяжеловестной абстракции, как собственный язык, хочется сразу увидеть рационализацию необходимости такого решения. А точно ли нельзя было сделать на чем-то существующем? Нельзя ли было сделать какую-то мета-таблицу, где в специальной колонке указывался бы тип запроса? (который @type=’board’, @type=’form’)

    Или чтобы это вообще был не SQL, а ЯП общего назначения? SQL это же про данные, а не логику. А то получается, что мы GUI пытаемся на SQL нарисовать. Короче, да, почему тут SQL я не очень понял. (не отрицаю что мое непонимание возможно исходит от слабого познания SQL. С SQL мне мало приходилось работать)


    1. vagon333
      13.07.2022 05:37

      Или чтобы это вообще был не SQL, а ЯП общего назначения?

      PGSQL хорошо расширяется. Вот и расширили до генерации контента и данных.
      Вполне логично.


    1. abbradar
      13.07.2022 09:03
      +1

      Согласен, про язык можно было рассказать чуть больше. Попробую это сделать здесь.

      Если кратко: свой язык делался потому что чесалось для скорости и удобства разработки. Были сначала мысли как-то на уровне определения явно разделять данные и отображение. Например, запросы писать на чистом pgSQL, а атрибуты для отображения задавать отдельно формулами (тоже на pgSQL). В итоге оказалось, что писать запросы так неудобно - формат, когда прямо в запросе есть атрибуты, разработчику решений позволяет работать эффективнее.

      Потом язык уже оброс своими фичами, которые специфичны для базы: например, оператор =>, который позволяет обходиться без JOIN, и операции с наследованием таблиц (которое у нас реализовано по-другому, чем в самом PostgreSQL). При этом мы стараемся держаться максимально близко к изначальному SQL - он зачастую хорошо известен целевой аудитории продукта, разработчикам (даже начинающим) бизнес-приложений. Хочется, чтобы они могли понять идею атрибутов за пару часов и уже эффективно работать.

      При этом абсолютно верно, что данные и отображение мешать - это в целом плохой тон, и мы тут именно что рисуем GUI на SQL. Тут две мысли. Во-первых, на практике для большинства форм и таблиц это кажется приемлемым решением, потому что "логики" в запросе, как таковой, почти нет -- если убрать все атрибуты, то это простой SELECT с возможными WHERE по нескольким параметрам, и с ORDER BY, который скорее уже часть отображения. Такие запросы составляют большую часть разработки бизнес-приложений.

      Во-вторых, мы хотим реализовать вызов user view (они же как функции) изнутри других user view, и написать гайдлайн, в котором будем советовать для любых нетривиальных запросов разделять так данные и отображение. Условно, один сложный запрос без атрибутов, и один или несколько, которые делают простой SELECT из него и только добавляют нужных опций (или дополнительно его фильтруют, скажем). Это всё в планах.

      Про ЯП общего назначения: не хотелось давать возможность писать произвольный код внутри запроса. SQL тут хорош как раз тем, что он (в изначальном виде) только про данные. Все атрибуты у нас, как и сам результат запроса, декларативны - это либо значения, либо формулы. Поэтому в итоге выходит, что весь UI задан набором таких значений и формул. Это, с одной стороны, ограничивает разработчика (можно было бы дать возможность, скажем, писать UI на JS, и разрешить вообще что угодно). С другой -- для большинства отображений это повышает скорость разработки и читаемость кода. Например. SELECT balance @{ cell_color = CASE WHEN balance < 0 THEN 'red' ELSE 'green' } - тут сразу понятно, что выбирается и какая логика раскраски ячеек. Ну и наконец, это позволяет нам гораздо свободнее внедрять различные оптимизации и улучшения в интерфейс, чем если бы возможен был произвольный код на любом уровне.

      Надеюсь, идея стала понятнее!


  1. vagon333
    13.07.2022 05:50
    +1

    Прекрасная идея и решение.

    Вопросы (много):

    База данных

    1. Можно ли позже менять схему базы: таблицы, колонки?
      Насколько сложно, если данные уже есть?

    2. Каким образом генерятся скрипты модификации данных?
      Можно ли вмешаться процесс генерации скрипта?

    3. Можно ли менять несколько записей дочерней таблицы одновременно?

    Пользовательский интерфейс:

    1. Можно ли расширить библиотеку UI controls?

    2. Как расширить существующий UI control (например, добавить свойстов) или добавить новый (например редактор диаграмм)?

    3. У вас данные и VIEW разделены, или FunQL генерит все вместе, представление, логику фронта и данные?

    4. Как происходит обновление данных в форме - обновляются только данные, или перестраивается View + Data ?

    5. Есть ли в планах UI designer именно как дизайнер, а не как код ?

    6. Для каждого UI контрола есть ли в планах завести свою формочку для упрощенной настройки, сместо редактирования кода?


    1. abbradar
      13.07.2022 08:11
      +1

      Про базу. На всякий случай: фронт и базу я тут буду различать, потому что базой можно пользоваться отдельно:

      1. Да, схемы, таблицы и колонки произвольные везде, кроме схемы public и автоматического числового id во все таблицы (его тоже есть планы не требовать, но потом);

      2. Поправьте, если не про то. Логика модификации данных не генерится из SELECTов, а сделана хитрее: в запрос внутри вставляются доп. колонки, и для каждой ячейки из запроса по API возвращается информация, из какой она реально записи. Фронт использует эту информацию, чтобы давать редактировать почти любые поля. Про вмешательство не понял, можно пример?

      3. У базы есть вызов /transaction, куда можно передавать произвольный список изменений. На уровне фронта раньше было явно, сейчас нет - пользователям оказались непонятны явные гранзакции. Про дочернюю таблицу не понял, уточните?

      Про интерфейс:

      1. Да, по сути графики из статьи - и есть произвольные контролы на форме. В iframe контрола прилетают данные из ячейки, и есть вызовы, чтобы данные менять;

      2. Расширять нельзя, не придумали пока, как - есть только разные атрибуты для настройки. Новый можно, как в (1);

      3. FunQL и сама база на самом деле вообще не знают, что есть фронт или какая-то логика отображения. По сути это просто SQL-база с добавленными (произвольными) атрибутами, которые можно интерпретировать, как угодно. Фронт же на основании значений атрибутов строит всю страницу.

      4. Перестраивается фронтом после каждого редактирования. Забавный пример: если редактируете "код" редактора кода, то он при сохранении сразу будет меняться.

      5. Да, планируется позже. При этом, скорее всего, в нём нельзя будет собрать совсем произвольный запрос, но всегда можно редактировать код.

      6. Хорошая мысль, потому что кажется, что такое можно сделать отдельно от (5), чуть ли не всплывающее окошко из редактора кода. Подумаем.


      1. vagon333
        13.07.2022 08:52
        +1

        База

        Про вмешательство не понял, можно пример?

        INSERT/UPDATE statement генерится автоматически, на базе какого-то шаблона/скрипта.
        Допустим мне нужно вставить свою логику в подготовку данных на серверной стороне (не на клиенте, чтоб избежать шаловливые ручки).
        Причем, речь не идет о SQL триггере, который может изменить данные позже. Речь именно о генерации скрипта и вмешательстве в данные перед выполнением запроса.

        Про дочернюю таблицу не понял, уточните?

        Допустим, в форме редактируется основная таблица и вложенный редактируемый список, реализованный на стороне базы как дочерняя таблица.
        Такая возможность сейчас есть?
        Если есть, редактирование дочернего списка реализовано как одна транзакция, вместе с изменением parent record, или как масса отдельных?

        UI

        В iframe контрола прилетают данные из ячейки, и есть вызовы, чтобы данные менять;

        У вас все UI элементы реализованы в отдельных iframe?
        Спрашиваю т.к. iframe имеет ограничения.

        Да, планируется позже. При этом, скорее всего, в нём нельзя будет собрать совсем произвольный запрос, но всегда можно редактировать код.

        Чтоб снизить затраты на отладку и поддержку UI, может есть смысл формировать запрос из кусочков, которые формируются для каждого UI контрола.
        UI контролы, в свою очередь, конфигурить через UI формы.
        Тогда пользователю легче настраивать свойства контролов, а вам меньше ошибок т.к. авто-генерация FunQL на базе контрол-форм проще в отладке и надежнее в поддержке.

        Респект за идею и реализацию.
        Некоторые модули допилить, но направление перспективное.
        Высокий порог вхождения отталкивает корпоративных разрабов (не хотят тратить время т.к. нельзя перепродать эти знания в другой компании), но для стартапов ваш продукт - решение.
        Можно устраивать ликбезы для стартапов.
        У многих стартапов недостаточно ресурсов нанять команды разрабов для MVP.

        Удачи


        1. abbradar
          13.07.2022 09:39
          +1

          База:

          1. Да, INSERT/UPDATE генерятся автоматически. Чтобы делать операции сложнее, чем в триггерах, и в одной транзакции, есть actions. Это примерно хранимые процедуры, но на JS и с нашим API. В них можно передавать произвольные штуки из фронта, и они могут возвращать, например, отображение, на которое после выполнения нужно перейти. Есть ещё несколько идей для похожей гибкости, например, виртуальные таблицы (тоже самое, что VIEW с INSTEAD OF-триггерами в Постгресе).

          2. Про дочернюю таблицу: да, так можно и сейчас всё будет в рамках одной транзакции. Про это мы ещё думаем -- пользователям тяжело работать "транзакционно", они привыкли, что отредактированное просто сразу сохраняется. Видимо, будем делать опциональную панель "Изменения" с кнопкой "Применить", которую можно будет включать атрибутом.

          UI:

          1. Нет, "родные" компоненты живут на той же странице. Давать доступ кастомным компонентам ко всему фронту страшно - хочется, чтобы доступ у компонента был только к своим собственным данным.

          2. Да, такие же мысли. Это хочется совместить с некоей "схемой" атрибутов, которая будет проверяться на стороне базы - чтобы ловить опечатки и тому подобные ошибки. Сейчас атрибуты не проверяются никак, кроме как глазами в UI "что всё работает".

          Про аудиторию: кроме MVP хочется попробовать стать и платформой для
          разработки бизнес-приложений. Предположение, что наша гибкость даёт возможность
          реализовывать сложные проекты, а цена разработки и лицензии при этом ниже, чем у
          гигантов навроде MS CRM. Проблему с техническим порогом вхождения стараемся
          решать близостью FunQL к SQL, и использованием стандартного JS в остальных
          местах. И ищем вендоров, которым интересно нас внедрять, чтобы внедряли не сами
          компании.

          Наконец, ещё думаем про себя как про возможную платформу для приложений: на базе
          нас можно реализовывать свои сервисы, и писать к ним свой интерфейс.

          Спасибо!


          1. vagon333
            13.07.2022 22:28
            +1

            База

            Да, INSERT/UPDATE генерятся автоматически. Чтобы делать операции сложнее, чем в триггерах, и в одной транзакции, есть actions.

            Вы планируете ERD дизайнер для сложных VIEW и INSERT/UPDATE операций?
            Может не рисовать таблицы в дизайнере, но хотя-бы отображать таблицы и связи.


            1. irinagorokhova
              14.07.2022 13:36
              +1

              Про визуальный редактор (дизайнер) для запросов или конструирования бизнес-процессов мы думали, планируем попробовать реализовать его в будушем. Но сейчас всё-таки делаем упор на код.

              Касательно визуального отображения, например, связей между таблицами - это можно реализовать через iframe-ы. Мы как-то в качестве эксперимента делали такой граф связей для одного из проектов. Думаем его еще немного докрутить и добавить в публичные инстансы и admin схему, чтобы разработчики использовали для своих нужд.


  1. antirek
    14.07.2022 12:38
    +1

    а можно ли в этом low-code как-то все-таки разрабатывать как разработчик? то есть есть ли low-git и можно ли как-то организовать low-deploy? ))

    т.е. вы разработали таск-трекер. вот он работает, люди пользуются. вы хотите сделать новую фишку, на другом аккаунте собрали таск-трекер с новой фишкой, потестировали, решили сделать на рабочем аккаунте. и как перенести?


    1. abbradar
      14.07.2022 13:57
      +1

      Есть возможность выгрузить схемы целиком в архив, где вся схема в YAML, а весь код -- в отдельных файлах. Потом её можно загрузить назад. Вот тут подробнее: https://wiki.ozma.io/ru/administration/save_restore. Страница сохранения-загрузки пока прикручена сбоку ¯\(ツ)/¯ Ну и по API можно дёргать, у нас партнёр написал себе скрипты для деплоя в одну команду.

      В будущем хотим сделать как раз git, то есть к каждому инстансу привязать репу, которая синкается со схемами в базе. Идея, что свои модули люди смогут выкладывать в открытый доступ на тот же GitHub, и свои собственные мы хотим выложить так же.