From 0b1dcecc9c3cd188b8f56f4fe6172ee2f34266ad Mon Sep 17 00:00:00 2001 From: ngosang Date: Sun, 19 Jun 2016 19:29:15 +0200 Subject: [PATCH 001/139] Update es_ES translation --- app/Locale/es_ES/translations.php | 388 +++++++++++++++--------------- 1 file changed, 194 insertions(+), 194 deletions(-) diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index a646e4efa..39757f74c 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -20,16 +20,16 @@ return array( 'Orange' => 'Naranja', 'Grey' => 'Gris', 'Brown' => 'Marrón', - 'Deep Orange' => 'Naranja Oscuro', - 'Dark Grey' => 'Gris Oscuro', + 'Deep Orange' => 'Naranja oscuro', + 'Dark Grey' => 'Gris oscuro', 'Pink' => 'Rosa', - 'Teal' => 'Verde Azulado', - 'Cyan' => 'Cián', + 'Teal' => 'Verde azulado', + 'Cyan' => 'Cian', 'Lime' => 'Lima', - 'Light Green' => 'Verde Claro', + 'Light Green' => 'Verde claro', 'Amber' => 'Ámbar', 'Save' => 'Guardar', - 'Login' => 'Iniciar sesión (Ingresar)', + 'Login' => 'Iniciar sesión (ingresar)', 'Official website:' => 'Página web oficial:', 'Unassigned' => 'No asignado', 'View this task' => 'Ver esta tarea', @@ -80,7 +80,7 @@ return array( 'Settings' => 'Preferencias', 'Application settings' => 'Preferencias de la aplicación', 'Language' => 'Idioma', - 'Webhook token:' => 'Token de los disparadores Web (webhooks):', + 'Webhook token:' => 'Token de los disparadores web (webhooks):', 'API token:' => 'Token de la API:', 'Database size:' => 'Tamaño de la base de datos:', 'Download the database' => 'Descargar la base de datos', @@ -175,7 +175,7 @@ return array( 'Your automatic action have been created successfully.' => 'La acción automatizada ha sido creada correctamente.', 'Unable to create your automatic action.' => 'No se puede crear esta acción automatizada.', 'Remove an action' => 'Eliminar una acción', - 'Unable to remove this action.' => 'No se puede eliminar esta accción.', + 'Unable to remove this action.' => 'No se puede eliminar esta acción.', 'Action removed successfully.' => 'La acción ha sido eliminada correctamente.', 'Automatic actions for the project "%s"' => 'Acciones automatizadas para el proyecto «%s»', 'Add an action' => 'Añadir una acción', @@ -315,9 +315,9 @@ return array( 'Project cloned successfully.' => 'Proyecto clonado correctamente.', 'Unable to clone this project.' => 'No se puede clonar este proyecto.', 'Enable email notifications' => 'Habilitar notificaciones por correo electrónico', - 'Task position:' => 'Posición de la tarea', - 'The task #%d have been opened.' => 'La tarea #%d ha sido abierta', - 'The task #%d have been closed.' => 'La tarea #%d ha sido cerrada', + 'Task position:' => 'Posición de la tarea:', + 'The task #%d have been opened.' => 'La tarea #%d ha sido abierta.', + 'The task #%d have been closed.' => 'La tarea #%d ha sido cerrada.', 'Sub-task updated' => 'Subtarea actualizada', 'Title:' => 'Título:', 'Status:' => 'Estado:', @@ -362,7 +362,7 @@ return array( 'Account type:' => 'Tipo de cuenta:', 'Edit profile' => 'Modificar perfil', 'Change password' => 'Cambiar contraseña', - 'Password modification' => 'Modificacion de contraseña', + 'Password modification' => 'Modificación de contraseña', 'External authentications' => 'Autenticación externa', 'Never connected.' => 'Nunca se ha conectado.', 'No external authentication enabled.' => 'Sin autenticación externa activa.', @@ -411,13 +411,13 @@ return array( 'About' => 'Acerca de', 'Database driver:' => 'Controlador de la base de datos (driver):', 'Board settings' => 'Preferencias del tablero', - 'Webhook settings' => 'Preferencias del disparador web (Webhook)', + 'Webhook settings' => 'Preferencias del disparador web (webhook)', 'Reset token' => 'Limpiar token', 'API endpoint:' => 'Endpoint del API:', 'Refresh interval for private board' => 'Intervalo de refresco del tablero privado', 'Refresh interval for public board' => 'Intervalo de refresco del tablero público', 'Task highlight period' => 'Periodo de realce de la tarea', - 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Periodo (en segundos) para considerar que una tarea fué modificada recientemente (0 para deshabilitar, 2 días por defecto)', + 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'Periodo (en segundos) para considerar que una tarea fue modificada recientemente (0 para deshabilitar, 2 días por defecto)', 'Frequency in second (60 seconds by default)' => 'Frecuencia en segundos (60 segundos por defecto)', 'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'Frecuencia en segundos (0 para deshabilitar esta característica, 10 segundos por defecto)', 'Application URL' => 'URL de la aplicación', @@ -435,7 +435,7 @@ return array( 'Dashboard' => 'Tablero', 'Confirmation' => 'Confirmación', 'Allow everybody to access to this project' => 'Permitir a cualquiera acceder a este proyecto', - 'Everybody have access to this project.' => 'Cualquiera tiene acceso a este proyecto', + 'Everybody have access to this project.' => 'Cualquiera tiene acceso a este proyecto.', 'Webhooks' => 'Disparadores web (webhooks)', 'API' => 'API', 'Create a comment from an external provider' => 'Crear un comentario a partir de un proveedor externo', @@ -504,8 +504,8 @@ return array( 'Task Title' => 'Título de la tarea', 'Untitled' => 'Sin título', 'Application default' => 'Predefinido por la aplicación', - 'Language:' => 'Idioma', - 'Timezone:' => 'Zona horaria', + 'Language:' => 'Idioma:', + 'Timezone:' => 'Zona horaria:', 'All columns' => 'Todas las columnas', 'Calendar' => 'Calendario', 'Next' => 'Siguiente', @@ -564,7 +564,7 @@ return array( '%dh' => '%dh', 'Expand tasks' => 'Expandir tareas', 'Collapse tasks' => 'Colapsar tareas', - 'Expand/collapse tasks' => 'Expande/colapasa tareas', + 'Expand/collapse tasks' => 'Expande/colapsa tareas', 'Close dialog box' => 'Cerrar caja de diálogo', 'Submit a form' => 'Enviar formulario', 'Board view' => 'Vista de tablero', @@ -597,14 +597,14 @@ return array( 'Executer' => 'Ejecutor', 'Time spent in the column' => 'Tiempo transcurrido en la columna', 'Task transitions' => 'Transiciones de tarea', - 'Task transitions export' => 'Eportar transiciones de tarea', - 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Este informe contiene todos los movimientos de columna para cada tarea con la fecha, el usuario y el tiempo transcurrido en cada trasición.', + 'Task transitions export' => 'Exportar transiciones de tarea', + 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Este informe contiene todos los movimientos de columna para cada tarea con la fecha, el usuario y el tiempo transcurrido en cada transición.', 'Currency rates' => 'Cambio de monedas', 'Rate' => 'Cambio', 'Change reference currency' => 'Cambiar moneda de referencia', 'Add a new currency rate' => 'Añadir nuevo cambio de moneda', 'Reference currency' => 'Moneda de referencia', - 'The currency rate have been added successfully.' => 'Se ha añadido el cambio de moneda correctamente.', + 'The currency rate have been added successfully.' => 'El cambio de moneda se ha añadido correctamente.', 'Unable to add this currency rate.' => 'No se puede añadir este cambio de moneda.', 'Webhook URL' => 'URL del disparador web (webhook)', '%s remove the assignee of the task %s' => '%s quita el responsable de la tarea %s', @@ -623,67 +623,67 @@ return array( '%s via Kanboard' => '%s vía Kanboard', 'Burndown chart for "%s"' => 'Trabajo pendiente para «%s»', 'Burndown chart' => 'Trabajo pendiente', - 'This chart show the task complexity over the time (Work Remaining).' => 'Este diagrama mestra la complejidad de la tarea a lo largo del tiempo (trabajo restante).', + 'This chart show the task complexity over the time (Work Remaining).' => 'Este diagrama muestra la complejidad de la tarea a lo largo del tiempo (trabajo restante).', 'Screenshot taken %s' => 'Pantallazo tomado el %s', 'Add a screenshot' => 'Añadir un pantallazo', 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Capture un patallazo y pulse CTRL+V o ⌘+V para pegar aquí.', 'Screenshot uploaded successfully.' => 'Pantallazo cargado correctamente.', 'SEK - Swedish Krona' => 'SEK - Corona sueca', 'Identifier' => 'Identificador', - 'Disable two factor authentication' => 'Desactivar la autenticación de dos factores', - 'Do you really want to disable the two factor authentication for this user: "%s"?' => '¿Realmentes quiere desactuvar la autenticación de dos factores para este usuario: "%s?"', + 'Disable two factor authentication' => 'Desactivar la autenticación en dos pasos', + 'Do you really want to disable the two factor authentication for this user: "%s"?' => '¿Realmente desea desactivar la autenticación en dos pasos para este usuario: «%s»?', 'Edit link' => 'Modificar enlace', 'Start to type task title...' => 'Empiece a escribir el título de la tarea...', - 'A task cannot be linked to itself' => 'Una tarea no puede se enlazada con sigo misma', + 'A task cannot be linked to itself' => 'Una tarea no se puede enlazar con sigo misma', 'The exact same link already exists' => 'El mismo enlace ya existe', 'Recurrent task is scheduled to be generated' => 'Tarea recurrente programada para ser generada', 'Score' => 'Puntuación', 'The identifier must be unique' => 'El identificador debe ser único', - 'This linked task id doesn\'t exists' => 'El id de tarea no existe', + 'This linked task id doesn\'t exists' => 'El identificador de tarea no existe', 'This value must be alphanumeric' => 'Este valor debe ser alfanumérico', 'Edit recurrence' => 'Modificar repetición', - 'Generate recurrent task' => 'Generar tarea recurrente', - 'Trigger to generate recurrent task' => 'Disparador para generar tarea recurrente', + 'Generate recurrent task' => 'Generar una tarea recurrente', + 'Trigger to generate recurrent task' => 'Disparador para generar una tarea recurrente', 'Factor to calculate new due date' => 'Factor para calcular la nueva fecha de entrega', 'Timeframe to calculate new due date' => 'Calendario para calcular la nueva fecha de entrega', 'Base date to calculate new due date' => 'Fecha base para calcular la nueva fecha de entrega', 'Action date' => 'Fecha de la acción', - 'Base date to calculate new due date: ' => 'Fecha base para calcular la nueva fecha de entrega:', - 'This task has created this child task: ' => 'Esta tarea ha cerado esta tarea hija:', + 'Base date to calculate new due date: ' => 'Fecha base para calcular la nueva fecha de entrega: ', + 'This task has created this child task: ' => 'Esta tarea ha creado esta tarea hija: ', 'Day(s)' => 'Día(s)', 'Existing due date' => 'Fecha de entrega existente', - 'Factor to calculate new due date: ' => 'Factor para calcular la nueva fecha de entrega:', + 'Factor to calculate new due date: ' => 'Factor para calcular la nueva fecha de entrega: ', 'Month(s)' => 'Mes(es)', 'Recurrence' => 'Repetición', 'This task has been created by: ' => 'Esta tarea ha sido creada por: ', 'Recurrent task has been generated:' => 'Tarea recurrente generada:', - 'Timeframe to calculate new due date: ' => 'Calendario para calcular la nueva fecha de entrega:', - 'Trigger to generate recurrent task: ' => 'Disparador para generar tarea recurrente', + 'Timeframe to calculate new due date: ' => 'Calendario para calcular la nueva fecha de entrega: ', + 'Trigger to generate recurrent task: ' => 'Disparador para generar una tarea recurrente: ', 'When task is closed' => 'Cuando la tarea es cerrada', 'When task is moved from first column' => 'Cuando la tarea es movida desde la primera columna', 'When task is moved to last column' => 'Cuando la tarea es movida a la última columna', 'Year(s)' => 'Año(s)', - 'Calendar settings' => 'Parámetros del Calendario', - 'Project calendar view' => 'Vista de Calendario para el Proyecto', - 'Project settings' => 'Parámetros del Proyecto', - 'Show subtasks based on the time tracking' => 'Mostrar subtareas en base al seguimiento de tiempo', + 'Calendar settings' => 'Preferencias del calendario', + 'Project calendar view' => 'Vista de calendario del proyecto', + 'Project settings' => 'Preferencias del proyecto', + 'Show subtasks based on the time tracking' => 'Mostrar subtareas en base al seguimiento temporal', 'Show tasks based on the creation date' => 'Mostrar tareas en base a la fecha de creación', - 'Show tasks based on the start date' => 'Mostrar tareas en base a la fecha de comienzo', + 'Show tasks based on the start date' => 'Mostrar tareas en base a la fecha de inicio', 'Subtasks time tracking' => 'Seguimiento de tiempo en subtareas', - 'User calendar view' => 'Vista de Calendario para el Usuario', - 'Automatically update the start date' => 'Actualizar automáticamente la fecha de comienzo', + 'User calendar view' => 'Vista de calendario del usuario', + 'Automatically update the start date' => 'Actualizar automáticamente la fecha de inicio', 'iCal feed' => 'Fuente iCal', 'Preferences' => 'Preferencias', 'Security' => 'Seguridad', - 'Two factor authentication disabled' => 'Autenticación de dos factores deshabilitada', - 'Two factor authentication enabled' => 'Autenticación de dos factores habilitada', + 'Two factor authentication disabled' => 'Autenticación en dos pasos deshabilitada', + 'Two factor authentication enabled' => 'Autenticación en dos pasos habilitada', 'Unable to update this user.' => 'No se puede actualizar este usuario.', 'There is no user management for private projects.' => 'No hay gestión de usuarios para proyectos privados.', 'User that will receive the email' => 'Usuario que recibirá el correo', 'Email subject' => 'Asunto del correo', 'Date' => 'Fecha', 'Add a comment log when moving the task between columns' => 'Añadir un comentario al mover la tarea entre columnas', - 'Move the task to another column when the category is changed' => 'Mover la tarea a otra columna cuando cambia la categoría', + 'Move the task to another column when the category is changed' => 'Mover la tarea a otra columna cuando cambie la categoría', 'Send a task by email to someone' => 'Enviar una tarea a alguien por correo', 'Reopen a task' => 'Reabrir tarea', 'Column change' => 'Cambio de columna', @@ -698,12 +698,12 @@ return array( 'Gravatar' => 'Gravatar', '%s moved the task %s to the first swimlane' => '%s movió la tarea %s a la primera calle', '%s moved the task %s to the swimlane "%s"' => '%s movió la tarea %s a la calle «%s»', - 'This report contains all subtasks information for the given date range.' => 'Este informe contiene todas la información de las subtareas para el rango proporcionado de fechas.', - 'This report contains all tasks information for the given date range.' => 'Este informe contiene todas la información de las tareas para el rango proporcionado de fechas.', + 'This report contains all subtasks information for the given date range.' => 'Este informe contiene toda la información de las subtareas para el rango de fechas proporcionado.', + 'This report contains all tasks information for the given date range.' => 'Este informe contiene toda la información de las tareas para el rango de fechas proporcionado.', 'Project activities for %s' => 'Actividades del proyecto para %s', 'view the board on Kanboard' => 'ver el tablero en Kanboard', 'The task have been moved to the first swimlane' => 'Se ha movido la tarea a la primera calle', - 'The task have been moved to another swimlane:' => 'Se ha movido la tarea a otra calle', + 'The task have been moved to another swimlane:' => 'Se ha movido la tarea a otra calle:', 'New title: %s' => 'Nuevo título: %s', 'The task is not assigned anymore' => 'La tarea ya no está asignada', 'New assignee: %s' => 'Nuevo responsable: %s', @@ -717,46 +717,46 @@ return array( 'Time spent changed: %sh' => 'Se ha cambiado el tiempo empleado: %sh', 'Time estimated changed: %sh' => 'Se ha cambiado el tiempo estimado: %sh', 'The field "%s" have been updated' => 'Se ha actualizado el campo «%s»', - 'The description has been modified:' => 'Se ha modificado la descripción', + 'The description has been modified:' => 'Se ha modificado la descripción:', 'Do you really want to close the task "%s" as well as all subtasks?' => '¿Realmente desea cerrar la tarea «%s» así como todas las subtareas?', 'I want to receive notifications for:' => 'Deseo recibir notificaciones para:', 'All tasks' => 'Todas las tareas', 'Only for tasks assigned to me' => 'Sólo para las tareas que me han sido asignadas', - 'Only for tasks created by me' => 'Sólo para las taread creadas por mí', - 'Only for tasks created by me and assigned to me' => 'Sólo para las tareas credas por mí y que me han sido asignadas', + 'Only for tasks created by me' => 'Sólo para las tareas creadas por mí', + 'Only for tasks created by me and assigned to me' => 'Sólo para las tareas creadas por mí y que me han sido asignadas', '%%Y-%%m-%%d' => '%%d/%%M/%%Y', 'Total for all columns' => 'Total para todas las columnas', 'You need at least 2 days of data to show the chart.' => 'Necesitas al menos 2 días de datos para mostrar el gráfico.', '<15m' => '<15m', '<30m' => '<30m', 'Stop timer' => 'Parar temporizador', - 'Start timer' => 'Arrancar temporizador', + 'Start timer' => 'Iniciar temporizador', 'Add project member' => 'Añadir miembro al proyecto', 'My activity stream' => 'Mi flujo de actividad', 'My calendar' => 'Mi calendario', 'Search tasks' => 'Buscar tareas', 'Reset filters' => 'Limpiar filtros', 'My tasks due tomorrow' => 'Mis tareas a entregar mañana', - 'Tasks due today' => 'Tareas a antregar hoy', - 'Tasks due tomorrow' => 'Taraes a entregar mañana', + 'Tasks due today' => 'Tareas a entregar hoy', + 'Tasks due tomorrow' => 'Tareas a entregar mañana', 'Tasks due yesterday' => 'Tareas a entregar ayer', 'Closed tasks' => 'Tareas cerradas', 'Open tasks' => 'Tareas abiertas', 'Not assigned' => 'No asignada', - 'View advanced search syntax' => 'Ver sintáxis avanzada de búsqueda', + 'View advanced search syntax' => 'Ver sintaxis de búsqueda avanzada', 'Overview' => 'Resumen', - 'Board/Calendar/List view' => 'Vista de Tablero/Calendario/Lista', + 'Board/Calendar/List view' => 'Vista de tablero/calendario/lista', 'Switch to the board view' => 'Cambiar a vista de tablero', 'Switch to the calendar view' => 'Cambiar a vista de calendario', 'Switch to the list view' => 'Cambiar a vista de lista', 'Go to the search/filter box' => 'Ir a caja de buscar/filtrar', 'There is no activity yet.' => 'Aún no hay actividades.', - 'No tasks found.' => 'No se ha hallado tarea alguna.', + 'No tasks found.' => 'No se ha encontrado ninguna tarea.', 'Keyboard shortcut: "%s"' => 'Atajo de teclado: %s', 'List' => 'Lista', 'Filter' => 'Filtro', 'Advanced search' => 'Búsqueda avanzada', - 'Example of query: ' => 'Ejemplo de query: ', + 'Example of query: ' => 'Ejemplo de consulta: ', 'Search by project: ' => 'Buscar por proyecto: ', 'Search by column: ' => 'Buscar por columna: ', 'Search by assignee: ' => 'Buscar por responsable: ', @@ -764,36 +764,36 @@ return array( 'Search by category: ' => 'Buscar por categoría: ', 'Search by description: ' => 'Buscar por descripción: ', 'Search by due date: ' => 'Buscar por fecha de entrega: ', - 'Lead and Cycle time for "%s"' => 'Plazo de Entrega y Ciclo para «%s»', + 'Lead and Cycle time for "%s"' => 'Plazo de entrega y ciclo para «%s»', 'Average time spent into each column for "%s"' => 'Tiempo medio empleado en cada columna para «%s»', 'Average time spent into each column' => 'Tiempo medio empleado en cada columna', 'Average time spent' => 'Tiempo medio empleado', 'This chart show the average time spent into each column for the last %d tasks.' => 'Esta gráfica muestra el tiempo medio empleado en cada columna para las últimas %d tareas.', - 'Average Lead and Cycle time' => 'Plazo Medio de Entrega y de Ciclo', - 'Average lead time: ' => 'Plazo Medio de entrega: ', - 'Average cycle time: ' => 'Tiempo Medio de Ciclo: ', - 'Cycle Time' => 'Tiempo de Ciclo', - 'Lead Time' => 'Plazo de Entrega', - 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Esta gráfica muestra el plazo medio de entrega y de ciclo para las %d últimas tareas transcurridas.', + 'Average Lead and Cycle time' => 'Plazo medio de entrega y de ciclo', + 'Average lead time: ' => 'Plazo medio de entrega: ', + 'Average cycle time: ' => 'Tiempo medio de ciclo: ', + 'Cycle Time' => 'Tiempo de ciclo', + 'Lead Time' => 'Plazo de entrega', + 'This chart show the average lead and cycle time for the last %d tasks over the time.' => 'Esta gráfica muestra el plazo medio de entrega y de ciclo para las %d últimas tareas.', 'Average time into each column' => 'Tiempo medio en cada columna', 'Lead and cycle time' => 'Plazo de entrega y de ciclo', 'Lead time: ' => 'Plazo de entrega: ', - 'Cycle time: ' => 'Tiempo de Ciclo: ', + 'Cycle time: ' => 'Tiempo de ciclo: ', 'Time spent into each column' => 'Tiempo empleado en cada columna', - 'The lead time is the duration between the task creation and the completion.' => 'El plazo de entrega es la duración entre la creación de la tarea su terminación.', - 'The cycle time is the duration between the start date and the completion.' => 'El tiempo de ciclo es la duración entre la fecha de inicio y su terminación.', - 'If the task is not closed the current time is used instead of the completion date.' => 'Si la tarea no se cierra, se usa la fecha actual en lugar de la de terminación.', + 'The lead time is the duration between the task creation and the completion.' => 'El plazo de entrega es la duración entre la creación de la tarea su finalización.', + 'The cycle time is the duration between the start date and the completion.' => 'El tiempo de ciclo es la duración entre la fecha de inicio y su finalización.', + 'If the task is not closed the current time is used instead of the completion date.' => 'Si la tarea no se cierra, se usa la fecha actual en lugar de la de finalización.', 'Set automatically the start date' => 'Poner la fecha de inicio de forma automática', 'Edit Authentication' => 'Modificar autenticación', 'Remote user' => 'Usuario remoto', - 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Los usuarios remotos no almacenan sus contraseñas en la base de datos Kanboard, por ejemplo: cuentas de LDAP, Google y Github', - 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Si marcas la caja de edición "Desactivar formulario de ingreso", se ignoran las credenciales entradas en el formulario de ingreso.', + 'Remote users do not store their password in Kanboard database, examples: LDAP, Google and Github accounts.' => 'Los usuarios remotos no almacenan sus contraseñas en la base de datos Kanboard, por ejemplo: cuentas de LDAP, Google y Github.', + 'If you check the box "Disallow login form", credentials entered in the login form will be ignored.' => 'Si marcas la opción "Desactivar formulario de ingreso", se ignoran las credenciales introducidas en el formulario de ingreso.', 'New remote user' => 'Nuevo usuario remoto', 'New local user' => 'Nuevo usuario local', - 'Default task color' => 'Color por defecto de tarea', - 'This feature does not work with all browsers.' => 'Esta característica no funciona con todos los navegadores', - 'There is no destination project available.' => 'No está disponible proyecto destino', - 'Trigger automatically subtask time tracking' => 'Disparar de forma automática seguimiento temporal de subtarea', + 'Default task color' => 'Color de la tarea por defecto', + 'This feature does not work with all browsers.' => 'Esta característica no funciona en todos los navegadores.', + 'There is no destination project available.' => 'No está disponible proyecto de destino.', + 'Trigger automatically subtask time tracking' => 'Disparar de forma automática el seguimiento temporal de subtarea', 'Include closed tasks in the cumulative flow diagram' => 'Incluir tareas cerradas en el diagrama de flujo acumulado', 'Current swimlane: %s' => 'Calle en curso: %s', 'Current column: %s' => 'Columna en curso: %s', @@ -805,20 +805,20 @@ return array( 'contributors' => 'contribuyentes', 'License:' => 'Licencia:', 'License' => 'Licencia', - 'Enter the text below' => 'Digita el texto de abajo', + 'Enter the text below' => 'Introduzca el texto a continuación', 'Gantt chart for %s' => 'Diagrama de Gantt para %s', - 'Sort by position' => 'Clasificado mediante posición', - 'Sort by date' => 'Clasificado mediante fecha', + 'Sort by position' => 'Ordenar por posición', + 'Sort by date' => 'Ordenar por fecha', 'Add task' => 'Añadir tarea', 'Start date:' => 'Fecha de inicio:', 'Due date:' => 'Fecha de entrega:', 'There is no start date or due date for this task.' => 'No hay fecha de inicio o de entrega para esta tarea.', - 'Moving or resizing a task will change the start and due date of the task.' => 'El mover o redimensionar una tarea cambiará la fecha inicio y de entrega de la misma.', + 'Moving or resizing a task will change the start and due date of the task.' => 'Mover o redimensionar una tarea cambiará la fecha inicio y de entrega de la misma.', 'There is no task in your project.' => 'No hay tareas en su proyecto.', - 'Gantt chart' => 'Digrama de Gantt', - 'People who are project managers' => 'Usuarios que son administradores de proyecto', - 'People who are project members' => 'Usuarios que son miembros de proyecto', - 'NOK - Norwegian Krone' => 'NOK - Coronoa Noruega', + 'Gantt chart' => 'Diagrama de Gantt', + 'People who are project managers' => 'Usuarios que son administradores del proyecto', + 'People who are project members' => 'Usuarios que son miembros del proyecto', + 'NOK - Norwegian Krone' => 'NOK - Corona Noruega', 'Show this column' => 'Mostrar esta columna', 'Hide this column' => 'Ocultar esta columna', 'open file' => 'abrir fichero', @@ -826,80 +826,80 @@ return array( 'Users overview' => 'Resumen de usuarios', 'Members' => 'Miembros', 'Shared project' => 'Proyecto compartido', - 'Project managers' => 'Administradores de proyecto', + 'Project managers' => 'Administradores del proyecto', 'Gantt chart for all projects' => 'Diagrama de Gantt para todos los proyectos', 'Projects list' => 'Lista de proyectos', 'Gantt chart for this project' => 'Diagrama de Gantt para este proyecto', 'Project board' => 'Tablero del proyecto', - 'End date:' => 'Fecha final', + 'End date:' => 'Fecha de fin:', 'There is no start date or end date for this project.' => 'No existe fecha de inicio o de fin para este proyecto.', - 'Projects Gantt chart' => 'Diagramas de Gantt de los proyectos', - 'Change task color when using a specific task link' => 'Cambiar colo de la tarea al usar un enlace específico a tarea', - 'Task link creation or modification' => 'Creación o modificación de enlace a tarea', + 'Projects Gantt chart' => 'Diagrama de Gantt de los proyectos', + 'Change task color when using a specific task link' => 'Cambiar el color de la tarea al usar un enlace específico a tarea', + 'Task link creation or modification' => 'Creación o modificación del enlace a tarea', 'Milestone' => 'Hito', 'Documentation: %s' => 'Documentación: %s', - 'Switch to the Gantt chart view' => 'Conmutar a vista de diagrama de Gantt', - 'Reset the search/filter box' => 'Limpiar la caja del filtro de búsqueda', + 'Switch to the Gantt chart view' => 'Cambiar a vista de diagrama de Gantt', + 'Reset the search/filter box' => 'Limpiar el filtro de búsqueda', 'Documentation' => 'Documentación', 'Table of contents' => 'Tabla de contenido', 'Gantt' => 'Gantt', 'Author' => 'Autor', 'Version' => 'Versión', 'Plugins' => 'Plugins', - 'There is no plugin loaded.' => 'No hay ningún plugin cargado', + 'There is no plugin loaded.' => 'No hay ningún plugin cargado.', 'Set maximum column height' => 'Establecer altura máxima de la columna', 'Remove maximum column height' => 'Eliminar altura máxima de la columna', 'My notifications' => 'Mis notificaciones', 'Custom filters' => 'Filtros personalizados', 'Your custom filter have been created successfully.' => 'Tus filtros personalizados han sido creados correctamente.', 'Unable to create your custom filter.' => 'No se ha podido crear tu filtro personalizado.', - 'Custom filter removed successfully.' => 'Filtro personalizado ha sido eliminado correctamente.', - 'Unable to remove this custom filter.' => 'No se ha podido eliminar tu filtro personalizado', + 'Custom filter removed successfully.' => 'El filtro personalizado ha sido eliminado correctamente.', + 'Unable to remove this custom filter.' => 'No se ha podido eliminar tu filtro personalizado.', 'Edit custom filter' => 'Modificar filtro personalizado', 'Your custom filter have been updated successfully.' => 'Tu filtro personalizado ha sido actualizado correctamente.', - 'Unable to update custom filter.' => 'No se ha podido actualizar tu filtro personalizado', + 'Unable to update custom filter.' => 'No se ha podido actualizar tu filtro personalizado.', 'Web' => 'Web', 'New attachment on task #%d: %s' => 'Nuevo adjunto en la tarea #%d: %s', 'New comment on task #%d' => 'Nuevo comentario en la tarea #%d', 'Comment updated on task #%d' => 'Comentario actualizado en la tarea #%d', 'New subtask on task #%d' => 'Nueva subtarea en la tarea #%d', - 'Subtask updated on task #%d' => 'La subtarea en la tarea #%d ha sido actualizada', + 'Subtask updated on task #%d' => 'Subtarea actualizada en la tarea #%d', 'New task #%d: %s' => 'Nueva tarea #%d: %s', 'Task updated #%d' => 'Tarea actualizada #%d', - 'Task #%d closed' => 'Tarea #%d ha sido cerrada', - 'Task #%d opened' => 'Tarea #%d ha sido abierta', - 'Column changed for task #%d' => 'Columna para tarea #%d ha sido cambiada', + 'Task #%d closed' => 'Tarea #%d cerrada', + 'Task #%d opened' => 'Tarea #%d abierta', + 'Column changed for task #%d' => 'Columna cambiada para la tarea #%d', 'New position for task #%d' => 'Nueva posición para tarea #%d', - 'Swimlane changed for task #%d' => 'Se cambió el swimlane de la tarea #%d', - 'Assignee changed on task #%d' => 'Se cambió el asignado de la tarea #%d', + 'Swimlane changed for task #%d' => 'Se cambió la calle de la tarea #%d', + 'Assignee changed on task #%d' => 'Se cambió el responsable de la tarea #%d', '%d overdue tasks' => '%d tareas atrasadas', 'Task #%d is overdue' => 'La tarea #%d está atrasada', - 'No new notifications.' => 'No hay nuevas notificaciones', + 'No new notifications.' => 'No hay nuevas notificaciones.', 'Mark all as read' => 'Marcar todo como leído', 'Mark as read' => 'Marcar como leído', - 'Total number of tasks in this column across all swimlanes' => 'Número total de tareas en esta columna por todas las swimlanes', - 'Collapse swimlane' => 'Contraer swimlane', - 'Expand swimlane' => 'Ampliar swimlane', + 'Total number of tasks in this column across all swimlanes' => 'Número total de tareas en esta columna a través de todas las calles', + 'Collapse swimlane' => 'Contraer calle', + 'Expand swimlane' => 'Ampliar calle', 'Add a new filter' => 'Añadir nuevo filtro', 'Share with all project members' => 'Compartir con todos los miembros del proyecto', 'Shared' => 'Compartido', - 'Owner' => 'Dueño', + 'Owner' => 'Propietario', 'Unread notifications' => 'Notificaciones sin leer', - 'Notification methods:' => 'Métodos de notificación', + 'Notification methods:' => 'Métodos de notificación:', 'Import tasks from CSV file' => 'Importar tareas desde archivo CSV', 'Unable to read your file' => 'No es posible leer el archivo', - '%d task(s) have been imported successfully.' => '%d tarea(s) han sido importadas correctamente', - 'Nothing have been imported!' => 'No se ha importado nada!', + '%d task(s) have been imported successfully.' => '%d tarea(s) han sido importadas correctamente.', + 'Nothing have been imported!' => '¡No se ha importado nada!', 'Import users from CSV file' => 'Importar usuarios desde archivo CSV', - '%d user(s) have been imported successfully.' => '%d usuario(s) se han importado correctamente', + '%d user(s) have been imported successfully.' => '%d usuario(s) se han importado correctamente.', 'Comma' => 'Coma', 'Semi-colon' => 'Punto y coma', 'Tab' => 'Tabulación', - 'Vertical bar' => 'Pleca', + 'Vertical bar' => 'Barra vertical', 'Double Quote' => 'Comilla doble', - 'Single Quote' => 'Comilla sencilla', - '%s attached a file to the task #%d' => '%s adjuntó un archivo a la tarea #%d', - 'There is no column or swimlane activated in your project!' => 'No hay ninguna columna o swimlane activada en su proyecto!', + 'Single Quote' => 'Comilla simple', + '%s attached a file to the task #%d' => '%s adjuntó un archivo en la tarea #%d', + 'There is no column or swimlane activated in your project!' => '¡No hay ninguna columna o calle activada en su proyecto!', 'Append filter (instead of replacement)' => 'Añadir filtro (en vez de reemplazar)', 'Append/Replace' => 'Añadir/Reemplazar', 'Append' => 'Añadir', @@ -912,63 +912,63 @@ return array( 'CSV File' => 'Archivo CSV', 'Instructions' => 'Indicaciones', 'Your file must use the predefined CSV format' => 'Su archivo debe utilizar el formato CSV predeterminado', - 'Your file must be encoded in UTF-8' => 'Su archivo debe ser codificado en UTF-8', + 'Your file must be encoded in UTF-8' => 'Su archivo debe estar codificado en UTF-8', 'The first row must be the header' => 'La primera fila debe ser el encabezado', 'Duplicates are not verified for you' => 'Los duplicados no serán verificados', 'The due date must use the ISO format: YYYY-MM-DD' => 'La fecha de entrega debe utilizar el formato ISO: AAAA-MM-DD', 'Download CSV template' => 'Descargar plantilla CSV', - 'No external integration registered.' => 'No se ha registrado integración externa', + 'No external integration registered.' => 'No se ha registrado integración externa.', 'Duplicates are not imported' => 'Los duplicados no son importados', - 'Usernames must be lowercase and unique' => 'Los nombres de usuario deben ser únicos y contener sólo minúsculas', + 'Usernames must be lowercase and unique' => 'Los nombres de usuario deben ser únicos y en minúsculas', 'Passwords will be encrypted if present' => 'Las contraseñas serán cifradas si es que existen', - '%s attached a new file to the task %s' => '%s adjuntó un nuevo archivo a la tarea %s', + '%s attached a new file to the task %s' => '%s adjuntó un nuevo archivo en la tarea %s', 'Link type' => 'Tipo de enlace', 'Assign automatically a category based on a link' => 'Asignar una categoría automáticamente basado en un enlace', - 'BAM - Konvertible Mark' => 'BAM - marco convertible', + 'BAM - Konvertible Mark' => 'BAM - Marco convertible', 'Assignee Username' => 'Nombre de usuario del responsable', 'Assignee Name' => 'Nombre del responsable', 'Groups' => 'Grupos', 'Members of %s' => 'Miembros de %s', 'New group' => 'Nuevo grupo', - 'Group created successfully.' => 'Grupo creado correctamente', - 'Unable to create your group.' => 'No es posible crear el grupo', + 'Group created successfully.' => 'Grupo creado correctamente.', + 'Unable to create your group.' => 'No es posible crear el grupo.', 'Edit group' => 'Modificar grupo', - 'Group updated successfully.' => 'Grupo actualizado correctamente', - 'Unable to update your group.' => 'No es posible actualizar el grupo', + 'Group updated successfully.' => 'Grupo actualizado correctamente.', + 'Unable to update your group.' => 'No es posible actualizar el grupo.', 'Add group member to "%s"' => 'Añadir un miembro del grupo a «%s»', - 'Group member added successfully.' => 'Miembro del grupo añadido correctamente', - 'Unable to add group member.' => 'No es posible añadir miembro del grupo', + 'Group member added successfully.' => 'Miembro del grupo añadido correctamente.', + 'Unable to add group member.' => 'No es posible añadir el miembro del grupo.', 'Remove user from group "%s"' => 'Eliminar usuario del grupo «%s»', - 'User removed successfully from this group.' => 'Usuario eliminado correctamente del grupo', - 'Unable to remove this user from the group.' => 'No es posible eliminar este usuario del grupo', + 'User removed successfully from this group.' => 'Usuario eliminado correctamente del grupo.', + 'Unable to remove this user from the group.' => 'No es posible eliminar este usuario del grupo.', 'Remove group' => 'Eliminar grupo', - 'Group removed successfully.' => 'Grupo eliminado correctamente', - 'Unable to remove this group.' => 'No es posible eliminar este grupo', + 'Group removed successfully.' => 'Grupo eliminado correctamente.', + 'Unable to remove this group.' => 'No es posible eliminar este grupo.', 'Project Permissions' => 'Permisos del proyecto', 'Manager' => 'Administrador', - 'Project Manager' => 'Administrador de proyecto', + 'Project Manager' => 'Administrador del proyecto', 'Project Member' => 'Miembro del proyecto', - 'Project Viewer' => 'Visor de proyectos', - 'Your account is locked for %d minutes' => 'Tu cuenta ha sido bloqueada por %d minuto(s)', + 'Project Viewer' => 'Observador del proyecto', + 'Your account is locked for %d minutes' => 'Tu cuenta ha sido bloqueada durante %d minutos', 'Invalid captcha' => 'CAPTCHA inválido', 'The name must be unique' => 'El nombre debe ser único', 'View all groups' => 'Ver todos los grupos', 'View group members' => 'Ver miembros del grupo', - 'There is no user available.' => 'No hay usuario disponible', - 'Do you really want to remove the user "%s" from the group "%s"?' => '¿Realmente desea eliminar el usuario "%s" del grupo «%s»?', - 'There is no group.' => 'No hay grupo', - 'External Id' => 'ID externo', - 'Add group member' => 'Añadir un miembro de grupo', + 'There is no user available.' => 'No hay usuario disponible.', + 'Do you really want to remove the user "%s" from the group "%s"?' => '¿Realmente desea eliminar el usuario «%s» del grupo «%s»?', + 'There is no group.' => 'No hay grupo.', + 'External Id' => 'Identificador externo', + 'Add group member' => 'Añadir un miembro al grupo', 'Do you really want to remove this group: "%s"?' => '¿Realmente desea eliminar este grupo: «%s»?', - 'There is no user in this group.' => 'No hay usuario en este grupo', + 'There is no user in this group.' => 'No hay usuario en este grupo.', 'Remove this user' => 'Eliminar este usuario', 'Permissions' => 'Permisos', 'Allowed Users' => 'Usuarios permitidos', - 'No user have been allowed specifically.' => 'Ningun usuario ha sido explícitamente permitido', + 'No user have been allowed specifically.' => 'Ningún usuario ha sido explícitamente permitido.', 'Role' => 'Rol', 'Enter user name...' => 'Ingresa nombre de usuario...', 'Allowed Groups' => 'Grupos permitidos', - 'No group have been allowed specifically.' => 'Ningun grupo ha sido explícitamente permitido', + 'No group have been allowed specifically.' => 'Ningún grupo ha sido explícitamente permitido.', 'Group' => 'Grupo', 'Group Name' => 'Nombre del grupo', 'Enter group name...' => 'Ingresa el nombre del grupo...', @@ -977,27 +977,27 @@ return array( 'Compare hours for "%s"' => 'Compara horas con «%s»', '%s mentioned you in the task #%d' => '%s te mencionó en la tarea #%d', '%s mentioned you in a comment on the task #%d' => '%s te mencionó en un comentario en la tarea #%d', - 'You were mentioned in the task #%d' => 'Te mencionaron en la tarea #%d', - 'You were mentioned in a comment on the task #%d' => 'Te mencionaron en un comentario en la tarea #%d', + 'You were mentioned in the task #%d' => 'Fuiste mencionado en la tarea #%d', + 'You were mentioned in a comment on the task #%d' => 'Fuiste mencionado en un comentario de la tarea #%d', 'Mentioned' => 'Mencionado', - 'Compare Estimated Time vs Actual Time' => 'Comparar Tiempo Estimado vs Tiempo Actual', + 'Compare Estimated Time vs Actual Time' => 'Comparar tiempo estimado vs tiempo actual', 'Estimated hours: ' => 'Horas estimadas: ', 'Actual hours: ' => 'Horas actuales: ', 'Hours Spent' => 'Horas gastadas', - 'Hours Estimated' => 'Hora Estimada', - 'Estimated Time' => 'Tiempo Estimado', - 'Actual Time' => 'Tiempo Actual', + 'Hours Estimated' => 'Horas estimadas', + 'Estimated Time' => 'Tiempo estimado', + 'Actual Time' => 'Tiempo actual', 'Estimated vs actual time' => 'Tiempo estimado vs real', - 'RUB - Russian Ruble' => 'RUB - rublo ruso', - 'Assign the task to the person who does the action when the column is changed' => 'Asignar la tarea a la persona que haga la acción al cambiar de columna', + 'RUB - Russian Ruble' => 'RUB - Rublo ruso', + 'Assign the task to the person who does the action when the column is changed' => 'Asignar la tarea a la persona que hace la acción al cambiar de columna', 'Close a task in a specific column' => 'Cerrar tarea en una columna especifica', 'Time-based One-time Password Algorithm' => 'Algoritmo basado en tiempo de un solo uso', - 'Two-Factor Provider: ' => 'Proveedor de autenticación de dos factores', - 'Disable two-factor authentication' => 'Deshabilitar autenticación de dos factores', - 'Enable two-factor authentication' => 'Habilitar autenticación de dos factorse', - 'There is no integration registered at the moment.' => 'No hay ninguna integración registrada por el momento', + 'Two-Factor Provider: ' => 'Proveedor de autenticación en dos pasos: ', + 'Disable two-factor authentication' => 'Deshabilitar autenticación en dos pasos', + 'Enable two-factor authentication' => 'Habilitar autenticación en dos pasos', + 'There is no integration registered at the moment.' => 'No hay ninguna integración registrada por el momento.', 'Password Reset for Kanboard' => 'Restablecimiento de contraseña para Kanboard', - 'Forgot password?' => '¿Olvidó contraseña?', + 'Forgot password?' => '¿Olvidó la contraseña?', 'Enable "Forget Password"' => 'Habilitar "olvidar contraseña"', 'Password Reset' => 'Restablecer contraseña', 'New password' => 'Nueva contraseña', @@ -1008,19 +1008,19 @@ return array( 'Creation' => 'Creación', 'Expiration' => 'Vencimiento', 'Password reset history' => 'Historial de restablecimiento de contraseña', - 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Todas las tareas de la columna "%s" y el swimlane «%s» se han cerrado correctamente', + 'All tasks of the column "%s" and the swimlane "%s" have been closed successfully.' => 'Todas las tareas de la columna "%s" y la calle «%s» se han cerrado correctamente.', 'Do you really want to close all tasks of this column?' => '¿Realmente desea cerrar todas las tareas de esta columna?', - '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d tarea(s) en la columna "%s" y el swimlane «%s» será(n) cerrada(s)', + '%d task(s) in the column "%s" and the swimlane "%s" will be closed.' => '%d tarea(s) en la columna "%s" y en la calle «%s» será(n) cerrada(s).', 'Close all tasks of this column' => 'Cerrar todas las tareas de esta columna', - 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Ningún plugin ha registrado un método de notificación para el proyecto. Aún puedes configurar notificaciones individuales en tu perfil de usuario', + 'No plugin has registered a project notification method. You can still configure individual notifications in your user profile.' => 'Ningún plugin ha registrado un método de notificación para el proyecto. Aún puedes configurar notificaciones individuales en tu perfil de usuario.', 'My dashboard' => 'Mi tablero', 'My profile' => 'Mi perfil', - 'Project owner: ' => 'Dueño del proyecto', - 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'El identificador de proyecto es opcional y debe ser alfanumérico. Ejemplo: MIPROYECTO', - 'Project owner' => 'Dueño del proyecto', - 'Those dates are useful for the project Gantt chart.' => 'Esas fechas son útiles para el diagrama de Gantt', - 'Private projects do not have users and groups management.' => 'Proyectos privados no cuentan con gestión de usuarios y grupos', - 'There is no project member.' => 'No existe miembro del proyecto', + 'Project owner: ' => 'Propietario del proyecto: ', + 'The project identifier is optional and must be alphanumeric, example: MYPROJECT.' => 'El identificador del proyecto es opcional y debe ser alfanumérico. Ejemplo: MIPROYECTO', + 'Project owner' => 'Propietario del proyecto', + 'Those dates are useful for the project Gantt chart.' => 'Esas fechas son útiles para el diagrama de Gantt.', + 'Private projects do not have users and groups management.' => 'Los proyectos privados no cuentan con gestión de usuarios y grupos.', + 'There is no project member.' => 'No existe miembro del proyecto.', 'Priority' => 'Prioridad', 'Task priority' => 'Prioridad de la tarea', 'General' => 'General', @@ -1028,16 +1028,16 @@ return array( 'Default priority' => 'Prioridad predeterminada', 'Lowest priority' => 'Prioridad más baja', 'Highest priority' => 'Prioridad más alta', - 'If you put zero to the low and high priority, this feature will be disabled.' => 'Si estableces la prioridad más baja y alta como cero esta función será deshabilitada', + 'If you put zero to the low and high priority, this feature will be disabled.' => 'Si estableces la prioridad más baja y más alta a cero esta función será deshabilitada.', 'Close a task when there is no activity' => 'Cerrar tarea cuando no haya actividad', 'Duration in days' => 'Duración en días', 'Send email when there is no activity on a task' => 'Enviar correo cuando no haya actividad en una tarea', - 'Unable to fetch link information.' => 'No es posible obtener información sobre el enlace', - 'Daily background job for tasks' => 'Tarea de fondo diaria para las tareas', + 'Unable to fetch link information.' => 'No es posible obtener información del enlace.', + 'Daily background job for tasks' => 'Trabajo en segundo plano diario para las tareas', 'Auto' => 'Automático', 'Related' => 'Relacionado', 'Attachment' => 'Adjunto', - 'Title not found' => 'Título no ha sido encontrado', + 'Title not found' => 'No se ha encontrado el título', 'Web Link' => 'Enlace web', 'External links' => 'Enlaces externos', 'Add external link' => 'Añadir enlace externo', @@ -1050,50 +1050,50 @@ return array( 'Copy and paste your link here...' => 'Copia y pega tu enlace aquí...', 'URL' => 'URL', 'Internal links' => 'Enlaces internos', - 'Assign to me' => 'Asignar a mí', + 'Assign to me' => 'Asignarme a mí', 'Me' => 'Yo', 'Do not duplicate anything' => 'No duplicar nada', 'Projects management' => 'Administración de proyectos', 'Users management' => 'Administración de usuarios', 'Groups management' => 'Administración de grupos', - 'Create from another project' => 'Crear de otro proyecto', + 'Create from another project' => 'Crear a partir de otro proyecto', 'open' => 'abierto', 'closed' => 'cerrado', - 'Priority:' => 'Prioridad', - 'Reference:' => 'Referencia', + 'Priority:' => 'Prioridad:', + 'Reference:' => 'Referencia:', 'Complexity:' => 'Complejidad:', - 'Swimlane:' => 'Swimlane:', + 'Swimlane:' => 'Calle:', 'Column:' => 'Columna:', 'Position:' => 'Posición:', 'Creator:' => 'Creador:', 'Time estimated:' => 'Tiempo estimado:', '%s hours' => '%s horas', 'Time spent:' => 'Tiempo gastado:', - 'Created:' => 'Creado', - 'Modified:' => 'Modificado', - 'Completed:' => 'Terminado', - 'Started:' => 'Iniciado', - 'Moved:' => 'Movido', + 'Created:' => 'Creado:', + 'Modified:' => 'Modificado:', + 'Completed:' => 'Finalizado:', + 'Started:' => 'Iniciado:', + 'Moved:' => 'Movido:', 'Task #%d' => 'Tarea #%d', - 'Date and time format' => 'Formato de hora y fecha', + 'Date and time format' => 'Formato de fecha y hora', 'Time format' => 'Formato de hora', - 'Start date: ' => 'Fecha de inicio', - 'End date: ' => 'Fecha de terminación', - 'New due date: ' => 'Nueva fecha de entrega', - 'Start date changed: ' => 'Fecha de inicio cambiada', + 'Start date: ' => 'Fecha de inicio: ', + 'End date: ' => 'Fecha de finalización: ', + 'New due date: ' => 'Nueva fecha de entrega: ', + 'Start date changed: ' => 'Fecha de inicio cambiada: ', 'Disable private projects' => 'Deshabilitar proyectos privados', 'Do you really want to remove this custom filter: "%s"?' => '¿Realmente desea eliminar este filtro personalizado: «%s»?', - 'Remove a custom filter' => 'Eliminar filtro personalizado', - 'User activated successfully.' => 'Usuario activado correctamente', - 'Unable to enable this user.' => 'No es posible habilitar este usuario', - 'User disabled successfully.' => 'Usuario deshabilitado correctamente', - 'Unable to disable this user.' => 'No es posible deshabilitar este usuario', - 'All files have been uploaded successfully.' => 'Todos los archivos han sido subidos correctamente', - 'View uploaded files' => 'Ver archivos subidos', - 'The maximum allowed file size is %sB.' => 'El límite de tamaño de archivo permitido para subir es %sB.', - 'Choose files again' => 'Eligir archivos de nuevo', + 'Remove a custom filter' => 'Eliminar el filtro personalizado', + 'User activated successfully.' => 'Usuario activado correctamente.', + 'Unable to enable this user.' => 'No es posible habilitar este usuario.', + 'User disabled successfully.' => 'Usuario deshabilitado correctamente.', + 'Unable to disable this user.' => 'No es posible deshabilitar este usuario.', + 'All files have been uploaded successfully.' => 'Todos los archivos han sido cargados correctamente.', + 'View uploaded files' => 'Ver archivos cargados', + 'The maximum allowed file size is %sB.' => 'El tamaño máximo de archivo es %sB.', + 'Choose files again' => 'Elegir archivos de nuevo', 'Drag and drop your files here' => 'Arrastra y suelta tus archivos aquí', - 'choose files' => 'Elegir archivos', + 'choose files' => 'elegir archivos', 'View profile' => 'Ver perfil', 'Two Factor' => 'Dos factores', 'Disable user' => 'Deshabilitar usuario', @@ -1113,16 +1113,16 @@ return array( 'Change column position' => 'Cambiar posición de la columna', 'Switch to the project overview' => 'Cambiar a vista general del proyecto', 'User filters' => 'Usar filtros', - 'Category filters' => 'Categoría y filtros', - 'Upload a file' => 'Subir archivo', + 'Category filters' => 'Filtros de categoría', + 'Upload a file' => 'Cargar archivo', 'View file' => 'Ver archivo', 'Last activity' => 'Última actividad', 'Change subtask position' => 'Cambiar posición de la subtarea', 'This value must be greater than %d' => 'Este valor debe ser mayor que %d', - 'Another swimlane with the same name exists in the project' => 'Ya existe otro swimlane con el mismo nombre en el proyecto', - 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => 'Ejemplo: http://ejemplo.kanboard.net/ (Usado para generar URLs absolutas)', - 'Actions duplicated successfully.' => 'Acción duplicada con exito.', - 'Unable to duplicate actions.' => 'No se ha podido duplicar la acción.', + 'Another swimlane with the same name exists in the project' => 'Ya existe otra calle con el mismo nombre en el proyecto', + 'Example: http://example.kanboard.net/ (used to generate absolute URLs)' => 'Ejemplo: http://ejemplo.kanboard.net/ (usado para generar URLs absolutas)', + 'Actions duplicated successfully.' => 'Acciones duplicadas con éxito.', + 'Unable to duplicate actions.' => 'No se han podido duplicar las acciones.', 'Add a new action' => 'Añadir una nueva acción', 'Import from another project' => 'Importar de otro proyecto', 'There is no action at the moment.' => 'No hay ninguna acción en este momento.', From 94b9235dcfd87f12f60a2e8ad07e39fcfd067f7c Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 23 Jun 2016 11:48:01 -0400 Subject: [PATCH 002/139] Merge manually PR #2367 and #2349 --- CONTRIBUTORS.md | 1 + doc/es_ES/calendar-configuration.markdown | 43 ++++++++ doc/es_ES/email-configuration.markdown | 115 ++++++++++++++++++++ doc/es_ES/kanban-vs-todo-and-scrum.markdown | 38 +++++++ 4 files changed, 197 insertions(+) create mode 100644 doc/es_ES/calendar-configuration.markdown create mode 100644 doc/es_ES/email-configuration.markdown create mode 100644 doc/es_ES/kanban-vs-todo-and-scrum.markdown diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 738374027..01ac3f2bb 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -53,6 +53,7 @@ Contributors: - [Jesusaplsoft](https://github.com/jesusaplsoft) - [Jesús Marín](https://github.com/alu0100502114) - [Jules Verhaeren](https://github.com/julesverhaeren) +- [JunglaCODE]https://github.com/junglaCODE) - [Karol J](https://github.com/dzudek) - [Kiswa](https://github.com/kiswa) - [Kralo](https://github.com/kralo) diff --git a/doc/es_ES/calendar-configuration.markdown b/doc/es_ES/calendar-configuration.markdown new file mode 100644 index 000000000..ccd832044 --- /dev/null +++ b/doc/es_ES/calendar-configuration.markdown @@ -0,0 +1,43 @@ +Configuración de calendarios +============================ + +Ir al menu de configuraciones, despues elegir cofiguracion de calendarios que se encuentra al lado izquierdo + +![Configuración de calendarios](https://kanboard.net/screenshots/documentation/calendar-settings.png) + +Existe dos diferentes calendarios en kanboard : + +- Calendarios de projectos +- Calendario por usuario (disponible desde el dashboard) + +Calendario por projectos +------------------------ + +Este calendario visualiza las tareas que se le asignan fechas de vencimiento y las tareas estan basadas sobre +la fecha de creación o el inicio de fecha + +### Visualizar tareas basadas en la fecha de creacion + +- El inicio de fecha del evento del calendario es la fecha de creacion de la tarea +- El finalización de fecha del evento es cuendo se completa una tarea + +### Visualizar tareas basadas en las fechas de inicio + +- La fecha de inicio del evento del calendario is la fecha de incio de la tarea +- Esta fecha puede ser definida manualmente. +- La fecha de finalización del evento es la fecha de terminación +- Si no hay una fecha de inicio de la tarea no aparece en el calendario. + +Calendarios por usuarios +------------------------ + +Este calendario visualiza solo las tareas asignadas para el usuario y opcionalmente la información de las subtareas + +### Visualizar subtareas basadas en el tiempo de tracking + +- Despliega la información de las subtareas desde el calendario o en el registro de la tabla de seguimiento de tiempo +- La intersección con los usuarios timetable es calculad + +### Las estimaciones muestran las subtareas ( la previsión de los trabajos futuros ) + +- Mostrar la estimación de los trabajos futuros de las subtareas en estado de "todo" y con un valor definido " estimación " . diff --git a/doc/es_ES/email-configuration.markdown b/doc/es_ES/email-configuration.markdown new file mode 100644 index 000000000..576c62ea6 --- /dev/null +++ b/doc/es_ES/email-configuration.markdown @@ -0,0 +1,115 @@ +Configuración del Email +======================= + +Configuración de usuarios +------------------------- + +Para recibir notificaciones por email los usuarios de Kanboard deben tener + +- Activar las notificaciones de su perfil +- Tener una dirección valida de email en su perfil +- Ser miembro del proyecto y que este tenga activo la opción de notificaciones + +Nota: El usuario que genera una sesión y que realiza alguna acción no recibe ninguna notificación, sólo otros miembros del proyecto. + +Comunicación con correos electronicos +------------------------------------- + +There are several email transports available: + +- SMTP +- Sendmail +- PHP mail funcion nativa +- Otros métodos que pueden ser proporcionados por externos : Postmark, Sendgrid and Mailgun + +Configuración del servidor +-------------------------- + +Por default, Kanboard usa el bundled PHP mail function para el envio de emails. +Porque usualmente el servidor no requiere una configuración y así tu servidor puede estar listo para enviar emails. + +Sin embargo, es posible usar otros metodos, como el protocolo SMTP y Sendmail + +### Configuración SMTP + +Renombrar el archivo `config.default.php` a `config.php` y modificar estos valores: + +```php +// We choose "smtp" as mail transport +define('MAIL_TRANSPORT', 'smtp'); + +// We define our server settings +define('MAIL_SMTP_HOSTNAME', 'mail.example.com'); +define('MAIL_SMTP_PORT', 25); + +// Credentials for authentication on the SMTP server (not mandatory) +define('MAIL_SMTP_USERNAME', 'username'); +define('MAIL_SMTP_PASSWORD', 'super password'); +``` + +También es posible utilizar una conexión segura, TLS or SSL: + +```php +define('MAIL_SMTP_ENCRYPTION', 'ssl'); // Valid values are "null", "ssl" or "tls" +``` + +### Configuración Sendmail + +Por default el comando para el sendmail esta `/usr/sbin/sendmail -bs` Pero usted puede personalizarlo en su archivo de configuración. + +Ejemplo: + +```php +// We choose "sendmail" as mail transport +define('MAIL_TRANSPORT', 'sendmail'); + +// If you need to change the sendmail command, replace the value +define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); +``` + +### PHP funcion nativa de email + +Esta es la configuración por default + +```php +define('MAIL_TRANSPORT', 'mail'); +``` + +### La dirección de correo electrónico del remitente + +Por default, los correos electrónicos utilizarán la dirección del remitente `notifications@kanboard.local`. +con este correo no es posible responderle + +Tu puedes personalizar esta direccion cambiando el valor de la constante `MAIL_FROM` en tu archivo de configuración + +```php +define('MAIL_FROM', 'kanboard@mydomain.tld'); +``` + +Esto puede ser útil si su configuracion del servidor SMTP no acepta una dirección por default. + +### Cómo mostrar un enlace a la tarea en las notificaciones ? + +Para hacer eso, tu tienes que especificar la URL de tu instalación de tu kanboard [Application Settings](https://kanboard.net/documentation/application-configuration). + +De manera predeterminada, no se define nada, por lo que no se mostrará los enlaces. + +Ejemplos : + +- http://demo.kanboard.net/ +- http://myserver/kanboard/ +- http://kanboard.mydomain.com/ + +No se olvide de la barra final `/`. + +Es necesario definir de forma manual debido a que Kanboard no puede adivinar la dirección URL de una secuencia de comandos de línea de comandos y algunas personas tienen una configuración muy específica. + +Solución de problemas +--------------------- + +Si no hay mensajes de correo electrónico se envían y que está seguro de que todo está configurado correctamente entonces: + +- Verificar el correo de spam +- Habilita el modo debug y verifique el archivo `data/debug.log`, Debería ver el error exacto +- Asegúrese de que el servidor o el proveedor de alojamiento le permite enviar mensajes de correo electrónico +- Si usa Selinux Permitir a PHP enviar emails diff --git a/doc/es_ES/kanban-vs-todo-and-scrum.markdown b/doc/es_ES/kanban-vs-todo-and-scrum.markdown new file mode 100644 index 000000000..ad9dd1a91 --- /dev/null +++ b/doc/es_ES/kanban-vs-todo-and-scrum.markdown @@ -0,0 +1,38 @@ +Kanban vs Todo lists and Scrum +============================== + +Kanban vs Todo lists +-------------------- + +### Todo lists (lista de tareas) : + +Fase unica (es solo una lista de tareas) +Multitarea posible (no eficiente) + +### Kanban: + +Multi fases, +Concentración absoluta para evitar multitareas por que se puede establecer un limite por columna para mejorar el progreso + + +Kanban vs Scrum +--------------- + +### Scrum: + +Los sprints son time-boxed, usualmente 2 o 4 semanas +No permitir cambios durante la iteración +La estimación es requerida +Utiliza la velocidad como métrica predeterminada +El tablero de Scrum se borra entre cada sprint +Scrum tiene funciones predefinidas como scrum master , los dueños del producto y el equipo +Una gran cantidad de reuniones: planeaciones, backlogs grooming, daily stand-up, retrospectiva + +### Kanban: + +- Fluido continuo +- Los cambios se pueden crear en cualquier momento +- La estimacion es opcional +- Usa la iniciativa del tiempo de ciclo para apresurar el performance +- el tablero Kanban board es persistente +- Kanban no impone estrictas restricciones y reuniones, el proceso es mas flexible From 75019b3a8e838f51bfac51bdbf9e6647faaaec1d Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 23 Jun 2016 12:27:34 -0400 Subject: [PATCH 003/139] Make embedded documentation available in multiple languages --- ChangeLog | 7 ++ app/Controller/DocumentationController.php | 70 ++++++++++++++---- doc/es_ES/kanban-vs-todo-and-scrum.markdown | 22 +++--- doc/{fr => fr_FR}/2fa.markdown | 0 doc/{fr => fr_FR}/analytics-tasks.markdown | 0 doc/{fr => fr_FR}/analytics.markdown | 0 .../application-configuration.markdown | 0 .../application-configuration.markup | 0 doc/{fr => fr_FR}/automatic-actions.markdown | 0 .../board-collapsed-expanded.markdown | 0 .../board-configuration.markdown | 0 ...zontal-scrolling-and-compact-view.markdown | 0 .../board-show-hide-columns.markdown | 0 .../calendar-configuration.markdown | 0 doc/{fr => fr_FR}/calendar.markdown | 0 doc/{fr => fr_FR}/closing-tasks.markdown | 0 .../create-tasks-by-email.markdown | 0 doc/{fr => fr_FR}/creating-projects.markdown | 0 doc/{fr => fr_FR}/creating-tasks.markdown | 0 doc/{fr => fr_FR}/currency-rate.markdown | 0 .../duplicate-move-tasks.markdown | 0 doc/{fr => fr_FR}/editing-projects.markdown | 0 .../gantt-chart-projects.markdown | 0 doc/{fr => fr_FR}/gantt-chart-tasks.markdown | 0 doc/{fr => fr_FR}/index.markdown | 0 .../kanban-vs-todo-and-scrum.markdown | 0 doc/{fr => fr_FR}/keyboard-shortcuts.markdown | 0 doc/{fr => fr_FR}/link-labels.markdown | 0 doc/{fr => fr_FR}/notifications.markdown | 0 .../project-configuration.markdown | 0 .../project-permissions.markdown | 0 doc/{fr => fr_FR}/project-types.markdown | 0 doc/{fr => fr_FR}/project-views.markdown | 0 doc/{fr => fr_FR}/recurring-tasks.markdown | 0 doc/{fr => fr_FR}/roles.markdown | 0 doc/{fr => fr_FR}/screenshots.markdown | 0 .../screenshots/automatic-action-creation.png | Bin .../screenshots/board-collapsed-mode.png | Bin .../screenshots/board-compact-mode.png | Bin .../screenshots/board-expanded-mode.png | Bin .../screenshots/board-task-limit.png | Bin doc/{fr => fr_FR}/screenshots/board-view.png | Bin .../screenshots/calendar-view.png | Bin doc/{fr => fr_FR}/screenshots/gantt-view.png | Bin doc/{fr => fr_FR}/screenshots/hide-column.png | Bin doc/{fr => fr_FR}/screenshots/list-view.png | Bin doc/{fr => fr_FR}/screenshots/new-project.png | Bin doc/{fr => fr_FR}/screenshots/new-user.png | Bin .../screenshots/project-disable-sharing.png | Bin .../screenshots/project-edition.png | Bin .../screenshots/project-enable-sharing.png | Bin .../screenshots/project-permissions.png | Bin .../screenshots/project-view.png | Bin doc/{fr => fr_FR}/screenshots/show-column.png | Bin .../screenshots/swimlane-configuration.png | Bin doc/{fr => fr_FR}/screenshots/swimlanes.png | Bin doc/{fr => fr_FR}/sharing-projects.markdown | 0 doc/{fr => fr_FR}/subtasks.markdown | 0 doc/{fr => fr_FR}/swimlanes.markdown | 0 doc/{fr => fr_FR}/task-links.markdown | 0 doc/{fr => fr_FR}/time-tracking.markdown | 0 doc/{fr => fr_FR}/transitions.markdown | 0 doc/{fr => fr_FR}/usage-examples.markdown | 0 doc/{fr => fr_FR}/user-management.markdown | 0 doc/{fr => fr_FR}/what-is-kanban.markdown | 0 65 files changed, 75 insertions(+), 24 deletions(-) rename doc/{fr => fr_FR}/2fa.markdown (100%) rename doc/{fr => fr_FR}/analytics-tasks.markdown (100%) rename doc/{fr => fr_FR}/analytics.markdown (100%) rename doc/{fr => fr_FR}/application-configuration.markdown (100%) rename doc/{fr => fr_FR}/application-configuration.markup (100%) rename doc/{fr => fr_FR}/automatic-actions.markdown (100%) rename doc/{fr => fr_FR}/board-collapsed-expanded.markdown (100%) rename doc/{fr => fr_FR}/board-configuration.markdown (100%) rename doc/{fr => fr_FR}/board-horizontal-scrolling-and-compact-view.markdown (100%) rename doc/{fr => fr_FR}/board-show-hide-columns.markdown (100%) rename doc/{fr => fr_FR}/calendar-configuration.markdown (100%) rename doc/{fr => fr_FR}/calendar.markdown (100%) rename doc/{fr => fr_FR}/closing-tasks.markdown (100%) rename doc/{fr => fr_FR}/create-tasks-by-email.markdown (100%) rename doc/{fr => fr_FR}/creating-projects.markdown (100%) rename doc/{fr => fr_FR}/creating-tasks.markdown (100%) rename doc/{fr => fr_FR}/currency-rate.markdown (100%) rename doc/{fr => fr_FR}/duplicate-move-tasks.markdown (100%) rename doc/{fr => fr_FR}/editing-projects.markdown (100%) rename doc/{fr => fr_FR}/gantt-chart-projects.markdown (100%) rename doc/{fr => fr_FR}/gantt-chart-tasks.markdown (100%) rename doc/{fr => fr_FR}/index.markdown (100%) rename doc/{fr => fr_FR}/kanban-vs-todo-and-scrum.markdown (100%) rename doc/{fr => fr_FR}/keyboard-shortcuts.markdown (100%) rename doc/{fr => fr_FR}/link-labels.markdown (100%) rename doc/{fr => fr_FR}/notifications.markdown (100%) rename doc/{fr => fr_FR}/project-configuration.markdown (100%) rename doc/{fr => fr_FR}/project-permissions.markdown (100%) rename doc/{fr => fr_FR}/project-types.markdown (100%) rename doc/{fr => fr_FR}/project-views.markdown (100%) rename doc/{fr => fr_FR}/recurring-tasks.markdown (100%) rename doc/{fr => fr_FR}/roles.markdown (100%) rename doc/{fr => fr_FR}/screenshots.markdown (100%) rename doc/{fr => fr_FR}/screenshots/automatic-action-creation.png (100%) rename doc/{fr => fr_FR}/screenshots/board-collapsed-mode.png (100%) rename doc/{fr => fr_FR}/screenshots/board-compact-mode.png (100%) rename doc/{fr => fr_FR}/screenshots/board-expanded-mode.png (100%) rename doc/{fr => fr_FR}/screenshots/board-task-limit.png (100%) rename doc/{fr => fr_FR}/screenshots/board-view.png (100%) rename doc/{fr => fr_FR}/screenshots/calendar-view.png (100%) rename doc/{fr => fr_FR}/screenshots/gantt-view.png (100%) rename doc/{fr => fr_FR}/screenshots/hide-column.png (100%) rename doc/{fr => fr_FR}/screenshots/list-view.png (100%) rename doc/{fr => fr_FR}/screenshots/new-project.png (100%) rename doc/{fr => fr_FR}/screenshots/new-user.png (100%) rename doc/{fr => fr_FR}/screenshots/project-disable-sharing.png (100%) rename doc/{fr => fr_FR}/screenshots/project-edition.png (100%) rename doc/{fr => fr_FR}/screenshots/project-enable-sharing.png (100%) rename doc/{fr => fr_FR}/screenshots/project-permissions.png (100%) rename doc/{fr => fr_FR}/screenshots/project-view.png (100%) rename doc/{fr => fr_FR}/screenshots/show-column.png (100%) rename doc/{fr => fr_FR}/screenshots/swimlane-configuration.png (100%) rename doc/{fr => fr_FR}/screenshots/swimlanes.png (100%) rename doc/{fr => fr_FR}/sharing-projects.markdown (100%) rename doc/{fr => fr_FR}/subtasks.markdown (100%) rename doc/{fr => fr_FR}/swimlanes.markdown (100%) rename doc/{fr => fr_FR}/task-links.markdown (100%) rename doc/{fr => fr_FR}/time-tracking.markdown (100%) rename doc/{fr => fr_FR}/transitions.markdown (100%) rename doc/{fr => fr_FR}/usage-examples.markdown (100%) rename doc/{fr => fr_FR}/user-management.markdown (100%) rename doc/{fr => fr_FR}/what-is-kanban.markdown (100%) diff --git a/ChangeLog b/ChangeLog index ebd6e38ef..20ffbca13 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,10 @@ +Version 1.0.31 (unreleased) +-------------- + +Improvements: + +* Make embedded documentation available in multiple languages + Version 1.0.30 -------------- diff --git a/app/Controller/DocumentationController.php b/app/Controller/DocumentationController.php index d86fb3c84..0d02ebda3 100644 --- a/app/Controller/DocumentationController.php +++ b/app/Controller/DocumentationController.php @@ -20,16 +20,7 @@ class DocumentationController extends BaseController $page = 'index'; } - if ($this->languageModel->getCurrentLanguage() === 'fr_FR') { - $filename = __DIR__.'/../../doc/fr/' . $page . '.markdown'; - } else { - $filename = __DIR__ . '/../../doc/' . $page . '.markdown'; - } - - if (!file_exists($filename)) { - $filename = __DIR__.'/../../doc/index.markdown'; - } - + $filename = $this->getPageFilename($page); $this->response->html($this->helper->layout->app('doc/show', $this->render($filename))); } @@ -83,10 +74,63 @@ class DocumentationController extends BaseController */ public function replaceImageUrl(array $matches) { - if ($this->languageModel->getCurrentLanguage() === 'fr_FR') { - return '('.$this->helper->url->base().'doc/fr/'.$matches[1].')'; + return '('.$this->getFileBaseUrl($matches[1]).')'; + } + + /** + * Get Markdown file according to the current language + * + * @access private + * @param string $page + * @return string + */ + private function getPageFilename($page) + { + return $this->getFileLocation($page . '.markdown') ?: + implode(DIRECTORY_SEPARATOR, array(ROOT_DIR, 'doc', 'index.markdown')); + } + + /** + * Get base URL for Markdown links + * + * @access private + * @param string $filename + * @return string + */ + private function getFileBaseUrl($filename) + { + $language = $this->languageModel->getCurrentLanguage(); + $path = $this->getFileLocation($filename); + + if (strpos($path, $language) !== false) { + $url = implode('/', array('doc', $language, $filename)); + } else { + $url = implode('/', array('doc', $filename)); } - return '('.$this->helper->url->base().'doc/'.$matches[1].')'; + return $this->helper->url->base().$url; + } + + /** + * Get file location according to the current language + * + * @access private + * @param string $filename + * @return string + */ + private function getFileLocation($filename) + { + $files = array( + implode(DIRECTORY_SEPARATOR, array(ROOT_DIR, 'doc', $this->languageModel->getCurrentLanguage(), $filename)), + implode(DIRECTORY_SEPARATOR, array(ROOT_DIR, 'doc', $filename)), + ); + + foreach ($files as $filename) { + if (file_exists($filename)) { + return $filename; + } + } + + return ''; } } diff --git a/doc/es_ES/kanban-vs-todo-and-scrum.markdown b/doc/es_ES/kanban-vs-todo-and-scrum.markdown index ad9dd1a91..6e8d9e6ce 100644 --- a/doc/es_ES/kanban-vs-todo-and-scrum.markdown +++ b/doc/es_ES/kanban-vs-todo-and-scrum.markdown @@ -6,13 +6,13 @@ Kanban vs Todo lists ### Todo lists (lista de tareas) : -Fase unica (es solo una lista de tareas) -Multitarea posible (no eficiente) +- Fase unica (es solo una lista de tareas) +- Multitarea posible (no eficiente) ### Kanban: -Multi fases, -Concentración absoluta para evitar multitareas por que se puede establecer un limite por columna para mejorar el progreso +- Multi fases, +- Concentración absoluta para evitar multitareas por que se puede establecer un limite por columna para mejorar el progreso Kanban vs Scrum @@ -20,13 +20,13 @@ Kanban vs Scrum ### Scrum: -Los sprints son time-boxed, usualmente 2 o 4 semanas -No permitir cambios durante la iteración -La estimación es requerida -Utiliza la velocidad como métrica predeterminada -El tablero de Scrum se borra entre cada sprint -Scrum tiene funciones predefinidas como scrum master , los dueños del producto y el equipo -Una gran cantidad de reuniones: planeaciones, backlogs grooming, daily stand-up, retrospectiva +- Los sprints son time-boxed, usualmente 2 o 4 semanas +- No permitir cambios durante la iteración +- La estimación es requerida +- Utiliza la velocidad como métrica predeterminada +- El tablero de Scrum se borra entre cada sprint +- Scrum tiene funciones predefinidas como scrum master , los dueños del producto y el equipo +- Una gran cantidad de reuniones: planeaciones, backlogs grooming, daily stand-up, retrospectiva ### Kanban: diff --git a/doc/fr/2fa.markdown b/doc/fr_FR/2fa.markdown similarity index 100% rename from doc/fr/2fa.markdown rename to doc/fr_FR/2fa.markdown diff --git a/doc/fr/analytics-tasks.markdown b/doc/fr_FR/analytics-tasks.markdown similarity index 100% rename from doc/fr/analytics-tasks.markdown rename to doc/fr_FR/analytics-tasks.markdown diff --git a/doc/fr/analytics.markdown b/doc/fr_FR/analytics.markdown similarity index 100% rename from doc/fr/analytics.markdown rename to doc/fr_FR/analytics.markdown diff --git a/doc/fr/application-configuration.markdown b/doc/fr_FR/application-configuration.markdown similarity index 100% rename from doc/fr/application-configuration.markdown rename to doc/fr_FR/application-configuration.markdown diff --git a/doc/fr/application-configuration.markup b/doc/fr_FR/application-configuration.markup similarity index 100% rename from doc/fr/application-configuration.markup rename to doc/fr_FR/application-configuration.markup diff --git a/doc/fr/automatic-actions.markdown b/doc/fr_FR/automatic-actions.markdown similarity index 100% rename from doc/fr/automatic-actions.markdown rename to doc/fr_FR/automatic-actions.markdown diff --git a/doc/fr/board-collapsed-expanded.markdown b/doc/fr_FR/board-collapsed-expanded.markdown similarity index 100% rename from doc/fr/board-collapsed-expanded.markdown rename to doc/fr_FR/board-collapsed-expanded.markdown diff --git a/doc/fr/board-configuration.markdown b/doc/fr_FR/board-configuration.markdown similarity index 100% rename from doc/fr/board-configuration.markdown rename to doc/fr_FR/board-configuration.markdown diff --git a/doc/fr/board-horizontal-scrolling-and-compact-view.markdown b/doc/fr_FR/board-horizontal-scrolling-and-compact-view.markdown similarity index 100% rename from doc/fr/board-horizontal-scrolling-and-compact-view.markdown rename to doc/fr_FR/board-horizontal-scrolling-and-compact-view.markdown diff --git a/doc/fr/board-show-hide-columns.markdown b/doc/fr_FR/board-show-hide-columns.markdown similarity index 100% rename from doc/fr/board-show-hide-columns.markdown rename to doc/fr_FR/board-show-hide-columns.markdown diff --git a/doc/fr/calendar-configuration.markdown b/doc/fr_FR/calendar-configuration.markdown similarity index 100% rename from doc/fr/calendar-configuration.markdown rename to doc/fr_FR/calendar-configuration.markdown diff --git a/doc/fr/calendar.markdown b/doc/fr_FR/calendar.markdown similarity index 100% rename from doc/fr/calendar.markdown rename to doc/fr_FR/calendar.markdown diff --git a/doc/fr/closing-tasks.markdown b/doc/fr_FR/closing-tasks.markdown similarity index 100% rename from doc/fr/closing-tasks.markdown rename to doc/fr_FR/closing-tasks.markdown diff --git a/doc/fr/create-tasks-by-email.markdown b/doc/fr_FR/create-tasks-by-email.markdown similarity index 100% rename from doc/fr/create-tasks-by-email.markdown rename to doc/fr_FR/create-tasks-by-email.markdown diff --git a/doc/fr/creating-projects.markdown b/doc/fr_FR/creating-projects.markdown similarity index 100% rename from doc/fr/creating-projects.markdown rename to doc/fr_FR/creating-projects.markdown diff --git a/doc/fr/creating-tasks.markdown b/doc/fr_FR/creating-tasks.markdown similarity index 100% rename from doc/fr/creating-tasks.markdown rename to doc/fr_FR/creating-tasks.markdown diff --git a/doc/fr/currency-rate.markdown b/doc/fr_FR/currency-rate.markdown similarity index 100% rename from doc/fr/currency-rate.markdown rename to doc/fr_FR/currency-rate.markdown diff --git a/doc/fr/duplicate-move-tasks.markdown b/doc/fr_FR/duplicate-move-tasks.markdown similarity index 100% rename from doc/fr/duplicate-move-tasks.markdown rename to doc/fr_FR/duplicate-move-tasks.markdown diff --git a/doc/fr/editing-projects.markdown b/doc/fr_FR/editing-projects.markdown similarity index 100% rename from doc/fr/editing-projects.markdown rename to doc/fr_FR/editing-projects.markdown diff --git a/doc/fr/gantt-chart-projects.markdown b/doc/fr_FR/gantt-chart-projects.markdown similarity index 100% rename from doc/fr/gantt-chart-projects.markdown rename to doc/fr_FR/gantt-chart-projects.markdown diff --git a/doc/fr/gantt-chart-tasks.markdown b/doc/fr_FR/gantt-chart-tasks.markdown similarity index 100% rename from doc/fr/gantt-chart-tasks.markdown rename to doc/fr_FR/gantt-chart-tasks.markdown diff --git a/doc/fr/index.markdown b/doc/fr_FR/index.markdown similarity index 100% rename from doc/fr/index.markdown rename to doc/fr_FR/index.markdown diff --git a/doc/fr/kanban-vs-todo-and-scrum.markdown b/doc/fr_FR/kanban-vs-todo-and-scrum.markdown similarity index 100% rename from doc/fr/kanban-vs-todo-and-scrum.markdown rename to doc/fr_FR/kanban-vs-todo-and-scrum.markdown diff --git a/doc/fr/keyboard-shortcuts.markdown b/doc/fr_FR/keyboard-shortcuts.markdown similarity index 100% rename from doc/fr/keyboard-shortcuts.markdown rename to doc/fr_FR/keyboard-shortcuts.markdown diff --git a/doc/fr/link-labels.markdown b/doc/fr_FR/link-labels.markdown similarity index 100% rename from doc/fr/link-labels.markdown rename to doc/fr_FR/link-labels.markdown diff --git a/doc/fr/notifications.markdown b/doc/fr_FR/notifications.markdown similarity index 100% rename from doc/fr/notifications.markdown rename to doc/fr_FR/notifications.markdown diff --git a/doc/fr/project-configuration.markdown b/doc/fr_FR/project-configuration.markdown similarity index 100% rename from doc/fr/project-configuration.markdown rename to doc/fr_FR/project-configuration.markdown diff --git a/doc/fr/project-permissions.markdown b/doc/fr_FR/project-permissions.markdown similarity index 100% rename from doc/fr/project-permissions.markdown rename to doc/fr_FR/project-permissions.markdown diff --git a/doc/fr/project-types.markdown b/doc/fr_FR/project-types.markdown similarity index 100% rename from doc/fr/project-types.markdown rename to doc/fr_FR/project-types.markdown diff --git a/doc/fr/project-views.markdown b/doc/fr_FR/project-views.markdown similarity index 100% rename from doc/fr/project-views.markdown rename to doc/fr_FR/project-views.markdown diff --git a/doc/fr/recurring-tasks.markdown b/doc/fr_FR/recurring-tasks.markdown similarity index 100% rename from doc/fr/recurring-tasks.markdown rename to doc/fr_FR/recurring-tasks.markdown diff --git a/doc/fr/roles.markdown b/doc/fr_FR/roles.markdown similarity index 100% rename from doc/fr/roles.markdown rename to doc/fr_FR/roles.markdown diff --git a/doc/fr/screenshots.markdown b/doc/fr_FR/screenshots.markdown similarity index 100% rename from doc/fr/screenshots.markdown rename to doc/fr_FR/screenshots.markdown diff --git a/doc/fr/screenshots/automatic-action-creation.png b/doc/fr_FR/screenshots/automatic-action-creation.png similarity index 100% rename from doc/fr/screenshots/automatic-action-creation.png rename to doc/fr_FR/screenshots/automatic-action-creation.png diff --git a/doc/fr/screenshots/board-collapsed-mode.png b/doc/fr_FR/screenshots/board-collapsed-mode.png similarity index 100% rename from doc/fr/screenshots/board-collapsed-mode.png rename to doc/fr_FR/screenshots/board-collapsed-mode.png diff --git a/doc/fr/screenshots/board-compact-mode.png b/doc/fr_FR/screenshots/board-compact-mode.png similarity index 100% rename from doc/fr/screenshots/board-compact-mode.png rename to doc/fr_FR/screenshots/board-compact-mode.png diff --git a/doc/fr/screenshots/board-expanded-mode.png b/doc/fr_FR/screenshots/board-expanded-mode.png similarity index 100% rename from doc/fr/screenshots/board-expanded-mode.png rename to doc/fr_FR/screenshots/board-expanded-mode.png diff --git a/doc/fr/screenshots/board-task-limit.png b/doc/fr_FR/screenshots/board-task-limit.png similarity index 100% rename from doc/fr/screenshots/board-task-limit.png rename to doc/fr_FR/screenshots/board-task-limit.png diff --git a/doc/fr/screenshots/board-view.png b/doc/fr_FR/screenshots/board-view.png similarity index 100% rename from doc/fr/screenshots/board-view.png rename to doc/fr_FR/screenshots/board-view.png diff --git a/doc/fr/screenshots/calendar-view.png b/doc/fr_FR/screenshots/calendar-view.png similarity index 100% rename from doc/fr/screenshots/calendar-view.png rename to doc/fr_FR/screenshots/calendar-view.png diff --git a/doc/fr/screenshots/gantt-view.png b/doc/fr_FR/screenshots/gantt-view.png similarity index 100% rename from doc/fr/screenshots/gantt-view.png rename to doc/fr_FR/screenshots/gantt-view.png diff --git a/doc/fr/screenshots/hide-column.png b/doc/fr_FR/screenshots/hide-column.png similarity index 100% rename from doc/fr/screenshots/hide-column.png rename to doc/fr_FR/screenshots/hide-column.png diff --git a/doc/fr/screenshots/list-view.png b/doc/fr_FR/screenshots/list-view.png similarity index 100% rename from doc/fr/screenshots/list-view.png rename to doc/fr_FR/screenshots/list-view.png diff --git a/doc/fr/screenshots/new-project.png b/doc/fr_FR/screenshots/new-project.png similarity index 100% rename from doc/fr/screenshots/new-project.png rename to doc/fr_FR/screenshots/new-project.png diff --git a/doc/fr/screenshots/new-user.png b/doc/fr_FR/screenshots/new-user.png similarity index 100% rename from doc/fr/screenshots/new-user.png rename to doc/fr_FR/screenshots/new-user.png diff --git a/doc/fr/screenshots/project-disable-sharing.png b/doc/fr_FR/screenshots/project-disable-sharing.png similarity index 100% rename from doc/fr/screenshots/project-disable-sharing.png rename to doc/fr_FR/screenshots/project-disable-sharing.png diff --git a/doc/fr/screenshots/project-edition.png b/doc/fr_FR/screenshots/project-edition.png similarity index 100% rename from doc/fr/screenshots/project-edition.png rename to doc/fr_FR/screenshots/project-edition.png diff --git a/doc/fr/screenshots/project-enable-sharing.png b/doc/fr_FR/screenshots/project-enable-sharing.png similarity index 100% rename from doc/fr/screenshots/project-enable-sharing.png rename to doc/fr_FR/screenshots/project-enable-sharing.png diff --git a/doc/fr/screenshots/project-permissions.png b/doc/fr_FR/screenshots/project-permissions.png similarity index 100% rename from doc/fr/screenshots/project-permissions.png rename to doc/fr_FR/screenshots/project-permissions.png diff --git a/doc/fr/screenshots/project-view.png b/doc/fr_FR/screenshots/project-view.png similarity index 100% rename from doc/fr/screenshots/project-view.png rename to doc/fr_FR/screenshots/project-view.png diff --git a/doc/fr/screenshots/show-column.png b/doc/fr_FR/screenshots/show-column.png similarity index 100% rename from doc/fr/screenshots/show-column.png rename to doc/fr_FR/screenshots/show-column.png diff --git a/doc/fr/screenshots/swimlane-configuration.png b/doc/fr_FR/screenshots/swimlane-configuration.png similarity index 100% rename from doc/fr/screenshots/swimlane-configuration.png rename to doc/fr_FR/screenshots/swimlane-configuration.png diff --git a/doc/fr/screenshots/swimlanes.png b/doc/fr_FR/screenshots/swimlanes.png similarity index 100% rename from doc/fr/screenshots/swimlanes.png rename to doc/fr_FR/screenshots/swimlanes.png diff --git a/doc/fr/sharing-projects.markdown b/doc/fr_FR/sharing-projects.markdown similarity index 100% rename from doc/fr/sharing-projects.markdown rename to doc/fr_FR/sharing-projects.markdown diff --git a/doc/fr/subtasks.markdown b/doc/fr_FR/subtasks.markdown similarity index 100% rename from doc/fr/subtasks.markdown rename to doc/fr_FR/subtasks.markdown diff --git a/doc/fr/swimlanes.markdown b/doc/fr_FR/swimlanes.markdown similarity index 100% rename from doc/fr/swimlanes.markdown rename to doc/fr_FR/swimlanes.markdown diff --git a/doc/fr/task-links.markdown b/doc/fr_FR/task-links.markdown similarity index 100% rename from doc/fr/task-links.markdown rename to doc/fr_FR/task-links.markdown diff --git a/doc/fr/time-tracking.markdown b/doc/fr_FR/time-tracking.markdown similarity index 100% rename from doc/fr/time-tracking.markdown rename to doc/fr_FR/time-tracking.markdown diff --git a/doc/fr/transitions.markdown b/doc/fr_FR/transitions.markdown similarity index 100% rename from doc/fr/transitions.markdown rename to doc/fr_FR/transitions.markdown diff --git a/doc/fr/usage-examples.markdown b/doc/fr_FR/usage-examples.markdown similarity index 100% rename from doc/fr/usage-examples.markdown rename to doc/fr_FR/usage-examples.markdown diff --git a/doc/fr/user-management.markdown b/doc/fr_FR/user-management.markdown similarity index 100% rename from doc/fr/user-management.markdown rename to doc/fr_FR/user-management.markdown diff --git a/doc/fr/what-is-kanban.markdown b/doc/fr_FR/what-is-kanban.markdown similarity index 100% rename from doc/fr/what-is-kanban.markdown rename to doc/fr_FR/what-is-kanban.markdown From 95751f391f336faf82ee2402a559247aef668e72 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 23 Jun 2016 15:43:37 -0400 Subject: [PATCH 004/139] Fixed broken CSV export --- ChangeLog | 4 ++++ app/Controller/ExportController.php | 33 +++++++++++++++-------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/ChangeLog b/ChangeLog index 20ffbca13..0277af691 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,6 +5,10 @@ Improvements: * Make embedded documentation available in multiple languages +Bug fixes: + +* Fixed broken CSV exports + Version 1.0.30 -------------- diff --git a/app/Controller/ExportController.php b/app/Controller/ExportController.php index b2fe0ebd6..27046c76f 100644 --- a/app/Controller/ExportController.php +++ b/app/Controller/ExportController.php @@ -31,22 +31,23 @@ class ExportController extends BaseController $data = $this->$model->$method($project['id'], $from, $to); $this->response->withFileDownload($filename.'.csv'); $this->response->csv($data); - } + } else { - $this->response->html($this->helper->layout->project('export/'.$action, array( - 'values' => array( - 'controller' => 'ExportController', - 'action' => $action, - 'project_id' => $project['id'], - 'from' => $from, - 'to' => $to, - ), - 'errors' => array(), - 'date_format' => $this->configModel->get('application_date_format'), - 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()), - 'project' => $project, - 'title' => $page_title, - ), 'export/sidebar')); + $this->response->html($this->helper->layout->project('export/'.$action, array( + 'values' => array( + 'controller' => 'ExportController', + 'action' => $action, + 'project_id' => $project['id'], + 'from' => $from, + 'to' => $to, + ), + 'errors' => array(), + 'date_format' => $this->configModel->get('application_date_format'), + 'date_formats' => $this->dateParser->getAvailableFormats($this->dateParser->getDateFormats()), + 'project' => $project, + 'title' => $page_title, + ), 'export/sidebar')); + } } /** @@ -76,7 +77,7 @@ class ExportController extends BaseController */ public function summary() { - $this->common('projectDailyColumnStats', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export')); + $this->common('projectDailyColumnStatsModel', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export')); } /** From d560f84b374fa1b3345dc582eddd6bb7b9138674 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 23 Jun 2016 20:26:19 -0400 Subject: [PATCH 005/139] Added models for tags --- app/Core/Base.php | 2 + app/Model/TagModel.php | 139 +++++++++++++++++++++++++ app/Model/TaskTagModel.php | 125 ++++++++++++++++++++++ app/Schema/Mysql.php | 25 ++++- app/Schema/Postgres.php | 24 ++++- app/Schema/Sqlite.php | 24 ++++- app/ServiceProvider/ClassProvider.php | 2 + tests/units/Model/TagModelTest.php | 120 +++++++++++++++++++++ tests/units/Model/TaskTagModelTest.php | 67 ++++++++++++ 9 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 app/Model/TagModel.php create mode 100644 app/Model/TaskTagModel.php create mode 100644 tests/units/Model/TagModelTest.php create mode 100644 tests/units/Model/TaskTagModelTest.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 7b4462e23..6712cbce4 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -86,6 +86,7 @@ use Pimple\Container; * @property \Kanboard\Model\SubtaskModel $subtaskModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel + * @property \Kanboard\Model\TagModel $tagModel * @property \Kanboard\Model\TaskModel $taskModel * @property \Kanboard\Model\TaskAnalyticModel $taskAnalyticModel * @property \Kanboard\Model\TaskCreationModel $taskCreationModel @@ -96,6 +97,7 @@ use Pimple\Container; * @property \Kanboard\Model\TaskModificationModel $taskModificationModel * @property \Kanboard\Model\TaskPositionModel $taskPositionModel * @property \Kanboard\Model\TaskStatusModel $taskStatusModel + * @property \Kanboard\Model\TaskTagModel $taskTagModel * @property \Kanboard\Model\TaskMetadataModel $taskMetadataModel * @property \Kanboard\Model\TimezoneModel $timezoneModel * @property \Kanboard\Model\TransitionModel $transitionModel diff --git a/app/Model/TagModel.php b/app/Model/TagModel.php new file mode 100644 index 000000000..1be05a66d --- /dev/null +++ b/app/Model/TagModel.php @@ -0,0 +1,139 @@ +db->table(self::TABLE)->asc('name')->findAll(); + } + + /** + * Get all tags by project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAllByProject($project_id) + { + return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('name')->findAll(); + } + + /** + * Get one tag + * + * @access public + * @param integer $tag_id + * @return array|null + */ + public function getById($tag_id) + { + return $this->db->table(self::TABLE)->eq('id', $tag_id)->findOne(); + } + + /** + * Get tag id from tag name + * + * @access public + * @param int $project_id + * @param string $tag + * @return integer + */ + public function getIdByName($project_id, $tag) + { + return $this->db + ->table(self::TABLE) + ->beginOr() + ->eq('project_id', 0) + ->eq('project_id', $project_id) + ->closeOr() + ->ilike('name', $tag) + ->asc('project_id') + ->findOneColumn('id'); + } + + /** + * Return tag id and create a new tag if necessary + * + * @access public + * @param int $project_id + * @param string $tag + * @return bool|int + */ + public function findOrCreateTag($project_id, $tag) + { + $tag_id = $this->getIdByName($project_id, $tag); + + if (empty($tag_id)) { + $tag_id = $this->create($project_id, $tag); + } + + return $tag_id; + } + + /** + * Add a new tag + * + * @access public + * @param int $project_id + * @param string $tag + * @return bool|int + */ + public function create($project_id, $tag) + { + return $this->db->table(self::TABLE)->persist(array( + 'project_id' => $project_id, + 'name' => $tag, + )); + } + + /** + * Update a tag + * + * @access public + * @param integer $tag_id + * @param string $tag + * @return bool + */ + public function update($tag_id, $tag) + { + return $this->db->table(self::TABLE)->eq('id', $tag_id)->update(array( + 'name' => $tag, + )); + } + + /** + * Remove a tag + * + * @access public + * @param integer $tag_id + * @return bool + */ + public function remove($tag_id) + { + return $this->db->table(self::TABLE)->eq('id', $tag_id)->remove(); + } +} diff --git a/app/Model/TaskTagModel.php b/app/Model/TaskTagModel.php new file mode 100644 index 000000000..74d82539e --- /dev/null +++ b/app/Model/TaskTagModel.php @@ -0,0 +1,125 @@ +db->table(TagModel::TABLE) + ->columns(TagModel::TABLE.'.id', TagModel::TABLE.'.name') + ->eq(self::TABLE.'.task_id', $task_id) + ->join(self::TABLE, 'tag_id', 'id') + ->findAll(); + } + + /** + * Get dictionary of tags + * + * @access public + * @param integer $task_id + * @return array + */ + public function getList($task_id) + { + $tags = $this->getAll($task_id); + return array_column($tags, 'name', 'id'); + } + + /** + * Add or update a list of tags to a task + * + * @access public + * @param integer $project_id + * @param integer $task_id + * @param string[] $tags + * @return boolean + */ + public function save($project_id, $task_id, array $tags) + { + $task_tags = $this->getList($task_id); + + return $this->addTags($project_id, $task_id, $task_tags, $tags) && + $this->removeTags($task_id, $task_tags, $tags); + } + + /** + * Associate a tag to a task + * + * @access public + * @param integer $task_id + * @param integer $tag_id + * @return boolean + */ + public function associate($task_id, $tag_id) + { + return $this->db->table(self::TABLE)->insert(array( + 'task_id' => $task_id, + 'tag_id' => $tag_id, + )); + } + + /** + * Dissociate a tag from a task + * + * @access public + * @param integer $task_id + * @param integer $tag_id + * @return boolean + */ + public function dissociate($task_id, $tag_id) + { + return $this->db->table(self::TABLE) + ->eq('task_id', $task_id) + ->eq('tag_id', $tag_id) + ->remove(); + } + + private function addTags($project_id, $task_id, $task_tags, $tags) + { + foreach ($tags as $tag) { + $tag_id = $this->tagModel->findOrCreateTag($project_id, $tag); + + if (! isset($task_tags[$tag_id]) && ! $this->associate($task_id, $tag_id)) { + return false; + } + } + + return true; + } + + private function removeTags($task_id, $task_tags, $tags) + { + foreach ($task_tags as $tag_id => $tag) { + if (! in_array($tag, $tags)) { + if (! $this->dissociate($task_id, $tag_id)) { + return false; + } + } + } + + return true; + } +} diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 934b063f1..82ccb8c8b 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -6,7 +6,30 @@ use PDO; use Kanboard\Core\Security\Token; use Kanboard\Core\Security\Role; -const VERSION = 110; +const VERSION = 111; + +function version_111(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE tags ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + project_id INT NOT NULL, + UNIQUE(project_id, name), + PRIMARY KEY(id) + ) ENGINE=InnoDB CHARSET=utf8 + "); + + $pdo->exec(" + CREATE TABLE task_has_tags ( + task_id INT NOT NULL, + tag_id INT NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(tag_id, task_id) + ) ENGINE=InnoDB CHARSET=utf8 + "); +} function version_110(PDO $pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 3ef49498b..229cbd25a 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -6,7 +6,29 @@ use PDO; use Kanboard\Core\Security\Token; use Kanboard\Core\Security\Role; -const VERSION = 89; +const VERSION = 90; + +function version_90(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + project_id INTEGER NOT NULL, + UNIQUE(project_id, name) + ) + "); + + $pdo->exec(" + CREATE TABLE task_has_tags ( + task_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(tag_id, task_id) + ) + "); +} function version_89(PDO $pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index 9ded7ed9b..dac348d41 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -6,7 +6,29 @@ use Kanboard\Core\Security\Token; use Kanboard\Core\Security\Role; use PDO; -const VERSION = 101; +const VERSION = 102; + +function version_102(PDO $pdo) +{ + $pdo->exec(" + CREATE TABLE tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + project_id INTEGER NOT NULL, + UNIQUE(project_id, name) + ) + "); + + $pdo->exec(" + CREATE TABLE task_has_tags ( + task_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(tag_id, task_id) + ) + "); +} function version_101(PDO $pdo) { diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 3e6efb029..778b4f9e7 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -60,6 +60,7 @@ class ClassProvider implements ServiceProviderInterface 'SubtaskModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', + 'TagModel', 'TaskModel', 'TaskAnalyticModel', 'TaskCreationModel', @@ -71,6 +72,7 @@ class ClassProvider implements ServiceProviderInterface 'TaskModificationModel', 'TaskPositionModel', 'TaskStatusModel', + 'TaskTagModel', 'TaskMetadataModel', 'TimezoneModel', 'TransitionModel', diff --git a/tests/units/Model/TagModelTest.php b/tests/units/Model/TagModelTest.php new file mode 100644 index 000000000..f090ab4a4 --- /dev/null +++ b/tests/units/Model/TagModelTest.php @@ -0,0 +1,120 @@ +container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + $this->assertEquals(2, $tagModel->create(1, 'Tag 1')); + $this->assertEquals(3, $tagModel->create(1, 'Tag 2')); + $this->assertFalse($tagModel->create(0, 'Tag 1')); + $this->assertFalse($tagModel->create(1, 'Tag 2')); + } + + public function testGetById() + { + $tagModel = new TagModel($this->container); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + + $tag = $tagModel->getById(1); + $this->assertEquals(0, $tag['project_id']); + $this->assertEquals('Tag 1', $tag['name']); + + $tag = $tagModel->getById(3); + $this->assertEmpty($tag); + } + + public function testGetAll() + { + $tagModel = new TagModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + $this->assertEquals(2, $tagModel->create(1, 'Tag 2')); + + $tags = $tagModel->getAll(); + $this->assertCount(2, $tags); + $this->assertEquals(0, $tags[0]['project_id']); + $this->assertEquals('Tag 1', $tags[0]['name']); + + $this->assertEquals(1, $tags[1]['project_id']); + $this->assertEquals('Tag 2', $tags[1]['name']); + } + + public function testGetAllByProjectId() + { + $tagModel = new TagModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + $this->assertEquals(2, $tagModel->create(1, 'B')); + $this->assertEquals(3, $tagModel->create(1, 'A')); + + $tags = $tagModel->getAllByProject(1); + $this->assertCount(2, $tags); + $this->assertEquals(1, $tags[0]['project_id']); + $this->assertEquals('A', $tags[0]['name']); + + $this->assertEquals(1, $tags[1]['project_id']); + $this->assertEquals('B', $tags[1]['name']); + } + + public function testGetIdByName() + { + $tagModel = new TagModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + $this->assertEquals(2, $tagModel->create(1, 'Tag 1')); + $this->assertEquals(3, $tagModel->create(1, 'Tag 3')); + + $this->assertEquals(1, $tagModel->getIdByName(1, 'tag 1')); + $this->assertEquals(1, $tagModel->getIdByName(0, 'tag 1')); + $this->assertEquals(3, $tagModel->getIdByName(1, 'TaG 3')); + } + + public function testFindOrCreateTag() + { + $tagModel = new TagModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + + $this->assertEquals(2, $tagModel->findOrCreateTag(1, 'Tag 2')); + $this->assertEquals(2, $tagModel->findOrCreateTag(1, 'Tag 2')); + $this->assertEquals(1, $tagModel->findOrCreateTag(1, 'Tag 1')); + } + + public function testRemove() + { + $tagModel = new TagModel($this->container); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + + $this->assertTrue($tagModel->remove(1)); + $this->assertFalse($tagModel->remove(1)); + } + + public function testUpdate() + { + $tagModel = new TagModel($this->container); + $this->assertEquals(1, $tagModel->create(0, 'Tag 1')); + $this->assertTrue($tagModel->update(1, 'Tag Updated')); + + $tag = $tagModel->getById(1); + $this->assertEquals(0, $tag['project_id']); + $this->assertEquals('Tag Updated', $tag['name']); + } +} diff --git a/tests/units/Model/TaskTagModelTest.php b/tests/units/Model/TaskTagModelTest.php new file mode 100644 index 000000000..c08b571fb --- /dev/null +++ b/tests/units/Model/TaskTagModelTest.php @@ -0,0 +1,67 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + $tagModel = new TagModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $tagModel->create(0, 'My tag 1')); + $this->assertEquals(2, $tagModel->create(0, 'My tag 2')); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + + $tags = $taskTagModel->getAll(1); + $this->assertCount(3, $tags); + + $this->assertEquals(1, $tags[0]['id']); + $this->assertEquals('My tag 1', $tags[0]['name']); + + $this->assertEquals(2, $tags[1]['id']); + $this->assertEquals('My tag 2', $tags[1]['name']); + + $this->assertEquals(3, $tags[2]['id']); + $this->assertEquals('My tag 3', $tags[2]['name']); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 3', 'My tag 1', 'My tag 4'))); + + $tags = $taskTagModel->getAll(1); + $this->assertCount(3, $tags); + + $this->assertEquals(1, $tags[0]['id']); + $this->assertEquals('My tag 1', $tags[0]['name']); + + $this->assertEquals(3, $tags[1]['id']); + $this->assertEquals('My tag 3', $tags[1]['name']); + + $this->assertEquals(4, $tags[2]['id']); + $this->assertEquals('My tag 4', $tags[2]['name']); + + $tags = $tagModel->getAll(); + $this->assertCount(4, $tags); + $this->assertEquals('My tag 1', $tags[0]['name']); + $this->assertEquals(0, $tags[0]['project_id']); + + $this->assertEquals('My tag 2', $tags[1]['name']); + $this->assertEquals(0, $tags[1]['project_id']); + + $this->assertEquals('My tag 3', $tags[2]['name']); + $this->assertEquals(1, $tags[2]['project_id']); + + $this->assertEquals('My tag 4', $tags[3]['name']); + $this->assertEquals(1, $tags[3]['project_id']); + } +} From 9e278a9370e3b651a4a545c0c0c0c256088ed187 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 24 Jun 2016 08:50:57 -0400 Subject: [PATCH 006/139] Use BoardFormatter to generate the board --- app/Api/BoardApi.php | 8 +- app/Controller/BoardAjaxController.php | 2 +- app/Controller/BoardViewController.php | 8 +- app/Filter/BaseFilter.php | 3 +- app/Formatter/BaseFormatter.php | 17 +- app/Formatter/BoardColumnFormatter.php | 79 +++++ app/Formatter/BoardFormatter.php | 19 +- app/Formatter/BoardSwimlaneFormatter.php | 105 +++++++ app/Formatter/BoardTaskFormatter.php | 80 +++++ app/Model/BoardModel.php | 60 ---- app/Model/TaskFinderModel.php | 20 -- app/Template/board/table_column.php | 4 +- app/functions.php | 26 ++ tests/units/Formatter/BoardFormatterTest.php | 311 +++++++++++++++++++ tests/units/FunctionTest.php | 21 ++ tests/units/Model/BoardTest.php | 121 -------- 16 files changed, 667 insertions(+), 217 deletions(-) create mode 100644 app/Formatter/BoardColumnFormatter.php create mode 100644 app/Formatter/BoardSwimlaneFormatter.php create mode 100644 app/Formatter/BoardTaskFormatter.php create mode 100644 tests/units/Formatter/BoardFormatterTest.php create mode 100644 tests/units/FunctionTest.php diff --git a/app/Api/BoardApi.php b/app/Api/BoardApi.php index aa5942af7..70f21c0e4 100644 --- a/app/Api/BoardApi.php +++ b/app/Api/BoardApi.php @@ -2,6 +2,8 @@ namespace Kanboard\Api; +use Kanboard\Formatter\BoardFormatter; + /** * Board API controller * @@ -13,6 +15,10 @@ class BoardApi extends BaseApi public function getBoard($project_id) { $this->checkProjectPermission($project_id); - return $this->boardModel->getBoard($project_id); + + return BoardFormatter::getInstance($this->container) + ->withProjectId($project_id) + ->withQuery($this->taskFinderModel->getExtendedQuery()) + ->format(); } } diff --git a/app/Controller/BoardAjaxController.php b/app/Controller/BoardAjaxController.php index 24914671f..9b721f060 100644 --- a/app/Controller/BoardAjaxController.php +++ b/app/Controller/BoardAjaxController.php @@ -134,7 +134,7 @@ class BoardAjaxController extends BaseController 'board_highlight_period' => $this->configModel->get('board_highlight_period'), 'swimlanes' => $this->taskLexer ->build($this->userSession->getFilters($project_id)) - ->format(BoardFormatter::getInstance($this->container)->setProjectId($project_id)) + ->format(BoardFormatter::getInstance($this->container)->withProjectId($project_id)) )); } } diff --git a/app/Controller/BoardViewController.php b/app/Controller/BoardViewController.php index 496fa995f..97c99d119 100644 --- a/app/Controller/BoardViewController.php +++ b/app/Controller/BoardViewController.php @@ -30,7 +30,11 @@ class BoardViewController extends BaseController $this->response->html($this->helper->layout->app('board/view_public', array( 'project' => $project, - 'swimlanes' => $this->boardModel->getBoard($project['id']), + 'swimlanes' => BoardFormatter::getInstance($this->container) + ->withProjectId($project['id']) + ->withQuery($this->taskFinderModel->getExtendedQuery()) + ->format() + , 'title' => $project['name'], 'description' => $project['description'], 'no_layout' => true, @@ -59,7 +63,7 @@ class BoardViewController extends BaseController 'board_highlight_period' => $this->configModel->get('board_highlight_period'), 'swimlanes' => $this->taskLexer ->build($search) - ->format(BoardFormatter::getInstance($this->container)->setProjectId($project['id'])) + ->format(BoardFormatter::getInstance($this->container)->withProjectId($project['id'])) ))); } } diff --git a/app/Filter/BaseFilter.php b/app/Filter/BaseFilter.php index 79a664bee..e029f4e12 100644 --- a/app/Filter/BaseFilter.php +++ b/app/Filter/BaseFilter.php @@ -43,8 +43,7 @@ abstract class BaseFilter */ public static function getInstance($value = null) { - $self = new static($value); - return $self; + return new static($value); } /** diff --git a/app/Formatter/BaseFormatter.php b/app/Formatter/BaseFormatter.php index a9f0ad15f..89c484370 100644 --- a/app/Formatter/BaseFormatter.php +++ b/app/Formatter/BaseFormatter.php @@ -3,8 +3,8 @@ namespace Kanboard\Formatter; use Kanboard\Core\Base; -use Kanboard\Core\Filter\FormatterInterface; use PicoDb\Table; +use Pimple\Container; /** * Class BaseFormatter @@ -22,12 +22,25 @@ abstract class BaseFormatter extends Base */ protected $query; + /** + * Get object instance + * + * @static + * @access public + * @param Container $container + * @return static + */ + public static function getInstance(Container $container) + { + return new static($container); + } + /** * Set query * * @access public * @param Table $query - * @return FormatterInterface + * @return $this */ public function withQuery(Table $query) { diff --git a/app/Formatter/BoardColumnFormatter.php b/app/Formatter/BoardColumnFormatter.php new file mode 100644 index 000000000..3d8f6e677 --- /dev/null +++ b/app/Formatter/BoardColumnFormatter.php @@ -0,0 +1,79 @@ +swimlaneId = $swimlaneId; + return $this; + } + + /** + * Set columns + * + * @access public + * @param array $columns + * @return $this + */ + public function withColumns(array $columns) + { + $this->columns = $columns; + return $this; + } + + /** + * Set tasks + * + * @access public + * @param array $tasks + * @return $this + */ + public function withTasks(array $tasks) + { + $this->tasks = $tasks; + return $this; + } + + /** + * Apply formatter + * + * @access public + * @return array + */ + public function format() + { + foreach ($this->columns as &$column) { + $column['tasks'] = BoardTaskFormatter::getInstance($this->container) + ->withTasks($this->tasks) + ->withSwimlaneId($this->swimlaneId) + ->withColumnId($column['id']) + ->format(); + + $column['nb_tasks'] = count($column['tasks']); + $column['score'] = (int) array_column_sum($column['tasks'], 'score'); + } + + return $this->columns; + } +} diff --git a/app/Formatter/BoardFormatter.php b/app/Formatter/BoardFormatter.php index dbc7cf210..562a97bc8 100644 --- a/app/Formatter/BoardFormatter.php +++ b/app/Formatter/BoardFormatter.php @@ -28,7 +28,7 @@ class BoardFormatter extends BaseFormatter implements FormatterInterface * @param integer $projectId * @return $this */ - public function setProjectId($projectId) + public function withProjectId($projectId) { $this->projectId = $projectId; return $this; @@ -42,15 +42,22 @@ class BoardFormatter extends BaseFormatter implements FormatterInterface */ public function format() { + $swimlanes = $this->swimlaneModel->getSwimlanes($this->projectId); + $columns = $this->columnModel->getAll($this->projectId); + $tasks = $this->query ->eq(TaskModel::TABLE.'.project_id', $this->projectId) ->asc(TaskModel::TABLE.'.position') ->findAll(); - return $this->boardModel->getBoard($this->projectId, function ($project_id, $column_id, $swimlane_id) use ($tasks) { - return array_filter($tasks, function (array $task) use ($column_id, $swimlane_id) { - return $task['column_id'] == $column_id && $task['swimlane_id'] == $swimlane_id; - }); - }); + if (empty($swimlanes) || empty($columns)) { + return array(); + } + + return BoardSwimlaneFormatter::getInstance($this->container) + ->withSwimlanes($swimlanes) + ->withColumns($columns) + ->withTasks($tasks) + ->format(); } } diff --git a/app/Formatter/BoardSwimlaneFormatter.php b/app/Formatter/BoardSwimlaneFormatter.php new file mode 100644 index 000000000..91b4bfd71 --- /dev/null +++ b/app/Formatter/BoardSwimlaneFormatter.php @@ -0,0 +1,105 @@ +swimlanes = $swimlanes; + return $this; + } + + /** + * Set columns + * + * @access public + * @param array $columns + * @return $this + */ + public function withColumns($columns) + { + $this->columns = $columns; + return $this; + } + + /** + * Set tasks + * + * @access public + * @param array $tasks + * @return $this + */ + public function withTasks(array $tasks) + { + $this->tasks = $tasks; + return $this; + } + + /** + * Apply formatter + * + * @access public + * @return array + */ + public function format() + { + $nb_swimlanes = count($this->swimlanes); + $nb_columns = count($this->columns); + + foreach ($this->swimlanes as &$swimlane) { + $swimlane['columns'] = BoardColumnFormatter::getInstance($this->container) + ->withSwimlaneId($swimlane['id']) + ->withColumns($this->columns) + ->withTasks($this->tasks) + ->format(); + + $swimlane['nb_swimlanes'] = $nb_swimlanes; + $swimlane['nb_columns'] = $nb_columns; + $swimlane['nb_tasks'] = array_column_sum($swimlane['columns'], 'nb_tasks'); + $swimlane['score'] = array_column_sum($swimlane['columns'], 'score'); + + $this->calculateStatsByColumnAcrossSwimlanes($swimlane['columns']); + } + + return $this->swimlanes; + } + + /** + * Calculate stats for each column acrosss all swimlanes + * + * @access protected + * @param array $columns + */ + protected function calculateStatsByColumnAcrossSwimlanes(array $columns) + { + foreach ($columns as $columnIndex => $column) { + if (! isset($this->swimlanes[0]['columns'][$columnIndex]['column_nb_tasks'])) { + $this->swimlanes[0]['columns'][$columnIndex]['column_nb_tasks'] = 0; + $this->swimlanes[0]['columns'][$columnIndex]['column_score'] = 0; + } + + $this->swimlanes[0]['columns'][$columnIndex]['column_nb_tasks'] += $column['nb_tasks']; + $this->swimlanes[0]['columns'][$columnIndex]['column_score'] += $column['score']; + } + } +} diff --git a/app/Formatter/BoardTaskFormatter.php b/app/Formatter/BoardTaskFormatter.php new file mode 100644 index 000000000..d9500710e --- /dev/null +++ b/app/Formatter/BoardTaskFormatter.php @@ -0,0 +1,80 @@ +tasks = $tasks; + return $this; + } + + /** + * Set columnId + * + * @access public + * @param integer $columnId + * @return $this + */ + public function withColumnId($columnId) + { + $this->columnId = $columnId; + return $this; + } + + /** + * Set swimlaneId + * + * @access public + * @param integer $swimlaneId + * @return $this + */ + public function withSwimlaneId($swimlaneId) + { + $this->swimlaneId = $swimlaneId; + return $this; + } + + /** + * Apply formatter + * + * @access public + * @return array + */ + public function format() + { + return array_values(array_filter($this->tasks, array($this, 'filterTasks'))); + } + + /** + * Keep only tasks of the given column and swimlane + * + * @access public + * @param array $task + * @return bool + */ + public function filterTasks(array $task) + { + return $task['column_id'] == $this->columnId && $task['swimlane_id'] == $this->swimlaneId; + } +} diff --git a/app/Model/BoardModel.php b/app/Model/BoardModel.php index d2718b474..4d5599360 100644 --- a/app/Model/BoardModel.php +++ b/app/Model/BoardModel.php @@ -93,66 +93,6 @@ class BoardModel extends Base return $this->boardModel->create($project_to, $columns); } - /** - * Get all tasks sorted by columns and swimlanes - * - * @access public - * @param integer $project_id - * @param callable $callback - * @return array - */ - public function getBoard($project_id, $callback = null) - { - $swimlanes = $this->swimlaneModel->getSwimlanes($project_id); - $columns = $this->columnModel->getAll($project_id); - $nb_columns = count($columns); - - for ($i = 0, $ilen = count($swimlanes); $i < $ilen; $i++) { - $swimlanes[$i]['columns'] = $columns; - $swimlanes[$i]['nb_columns'] = $nb_columns; - $swimlanes[$i]['nb_tasks'] = 0; - $swimlanes[$i]['nb_swimlanes'] = $ilen; - - for ($j = 0; $j < $nb_columns; $j++) { - $column_id = $columns[$j]['id']; - $swimlane_id = $swimlanes[$i]['id']; - - if (! isset($swimlanes[0]['columns'][$j]['nb_column_tasks'])) { - $swimlanes[0]['columns'][$j]['nb_column_tasks'] = 0; - $swimlanes[0]['columns'][$j]['total_score'] = 0; - } - - $swimlanes[$i]['columns'][$j]['tasks'] = $callback === null ? $this->taskFinderModel->getTasksByColumnAndSwimlane($project_id, $column_id, $swimlane_id) : $callback($project_id, $column_id, $swimlane_id); - $swimlanes[$i]['columns'][$j]['nb_tasks'] = count($swimlanes[$i]['columns'][$j]['tasks']); - $swimlanes[$i]['columns'][$j]['score'] = $this->getColumnSum($swimlanes[$i]['columns'][$j]['tasks'], 'score'); - $swimlanes[$i]['nb_tasks'] += $swimlanes[$i]['columns'][$j]['nb_tasks']; - $swimlanes[0]['columns'][$j]['nb_column_tasks'] += $swimlanes[$i]['columns'][$j]['nb_tasks']; - $swimlanes[0]['columns'][$j]['total_score'] += $swimlanes[$i]['columns'][$j]['score']; - } - } - - return $swimlanes; - } - - /** - * Calculate the sum of the defined field for a list of tasks - * - * @access public - * @param array $tasks - * @param string $field - * @return integer - */ - public function getColumnSum(array &$tasks, $field) - { - $sum = 0; - - foreach ($tasks as $task) { - $sum += $task[$field]; - } - - return $sum; - } - /** * Get the total of tasks per column * diff --git a/app/Model/TaskFinderModel.php b/app/Model/TaskFinderModel.php index 8b636e28a..0e99c407b 100644 --- a/app/Model/TaskFinderModel.php +++ b/app/Model/TaskFinderModel.php @@ -152,26 +152,6 @@ class TaskFinderModel extends Base ->join(ProjectModel::TABLE, 'id', 'project_id', TaskModel::TABLE); } - /** - * Get all tasks shown on the board (sorted by position) - * - * @access public - * @param integer $project_id Project id - * @param integer $column_id Column id - * @param integer $swimlane_id Swimlane id - * @return array - */ - public function getTasksByColumnAndSwimlane($project_id, $column_id, $swimlane_id = 0) - { - return $this->getExtendedQuery() - ->eq(TaskModel::TABLE.'.project_id', $project_id) - ->eq(TaskModel::TABLE.'.column_id', $column_id) - ->eq(TaskModel::TABLE.'.swimlane_id', $swimlane_id) - ->eq(TaskModel::TABLE.'.is_active', TaskModel::STATUS_OPEN) - ->asc(TaskModel::TABLE.'.position') - ->findAll(); - } - /** * Get all tasks for a given project and status * diff --git a/app/Template/board/table_column.php b/app/Template/board/table_column.php index f7a9f6ad3..6336234a4 100644 --- a/app/Template/board/table_column.php +++ b/app/Template/board/table_column.php @@ -18,9 +18,9 @@ - 1 && ! empty($column['nb_column_tasks'])): ?> + 1 && ! empty($column['column_nb_tasks'])): ?> - () + () diff --git a/app/functions.php b/app/functions.php index b759763f4..99431d9e2 100644 --- a/app/functions.php +++ b/app/functions.php @@ -2,6 +2,32 @@ use Kanboard\Core\Translator; +/** + * Sum all values from a single column in the input array + * + * $input = [ + * ['column' => 2'], ['column' => 3'] + * ] + * + * array_column_sum($input, 'column') returns 5 + * + * @param array $input + * @param string $column + * @return double + */ +function array_column_sum(array &$input, $column) +{ + $sum = 0.0; + + foreach ($input as &$row) { + if (isset($row[$column])) { + $sum += (float) $row[$column]; + } + } + + return $sum; +} + /** * Build version number from git-archive output * diff --git a/tests/units/Formatter/BoardFormatterTest.php b/tests/units/Formatter/BoardFormatterTest.php new file mode 100644 index 000000000..02b0b5185 --- /dev/null +++ b/tests/units/Formatter/BoardFormatterTest.php @@ -0,0 +1,311 @@ +container); + $swimlaneModel = new SwimlaneModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $swimlaneModel->create(array('name' => 'Swimlane 1', 'project_id' => 1))); + $this->assertEquals(2, $swimlaneModel->create(array('name' => 'Swimlane 2', 'project_id' => 1))); + + // 2 task within the same column but no score + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task 1', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task 2', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 1))); + + // 2 tasks in the same column with score + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task 3', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 1, 'score' => 4))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task 4', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 1, 'score' => 5))); + + // 1 task in 2nd column + $this->assertEquals(5, $taskCreationModel->create(array('title' => 'Task 5', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 2))); + + // tasks in same column but different swimlanes + $this->assertEquals(6, $taskCreationModel->create(array('title' => 'Task 6', 'project_id' => 1, 'swimlane_id' => 0, 'column_id' => 3, 'score' => 1))); + $this->assertEquals(7, $taskCreationModel->create(array('title' => 'Task 7', 'project_id' => 1, 'swimlane_id' => 1, 'column_id' => 3, 'score' => 2))); + $this->assertEquals(8, $taskCreationModel->create(array('title' => 'Task 8', 'project_id' => 1, 'swimlane_id' => 2, 'column_id' => 3, 'score' => 3))); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(3, $board); + + $this->assertEquals('Default swimlane', $board[0]['name']); + $this->assertCount(4, $board[0]['columns']); + $this->assertEquals(3, $board[0]['nb_swimlanes']); + $this->assertEquals(4, $board[0]['nb_columns']); + $this->assertEquals(6, $board[0]['nb_tasks']); + $this->assertEquals(10, $board[0]['score']); + + $this->assertEquals(4, $board[0]['columns'][0]['column_nb_tasks']); + $this->assertEquals(1, $board[0]['columns'][1]['column_nb_tasks']); + $this->assertEquals(3, $board[0]['columns'][2]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][3]['column_nb_tasks']); + + $this->assertEquals(9, $board[0]['columns'][0]['column_score']); + $this->assertEquals(0, $board[0]['columns'][1]['column_score']); + $this->assertEquals(6, $board[0]['columns'][2]['column_score']); + $this->assertEquals(0, $board[0]['columns'][3]['column_score']); + + $this->assertSame(9, $board[0]['columns'][0]['score']); + $this->assertSame(0, $board[0]['columns'][1]['score']); + $this->assertSame(1, $board[0]['columns'][2]['score']); + $this->assertSame(0, $board[0]['columns'][3]['score']); + + $this->assertSame(4, $board[0]['columns'][0]['nb_tasks']); + $this->assertSame(1, $board[0]['columns'][1]['nb_tasks']); + $this->assertSame(1, $board[0]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][3]['nb_tasks']); + + $this->assertEquals('Task 1', $board[0]['columns'][0]['tasks'][0]['title']); + $this->assertEquals('Task 2', $board[0]['columns'][0]['tasks'][1]['title']); + $this->assertEquals('Task 3', $board[0]['columns'][0]['tasks'][2]['title']); + $this->assertEquals('Task 4', $board[0]['columns'][0]['tasks'][3]['title']); + $this->assertEquals('Task 5', $board[0]['columns'][1]['tasks'][0]['title']); + $this->assertEquals('Task 6', $board[0]['columns'][2]['tasks'][0]['title']); + + $this->assertEquals('Swimlane 1', $board[1]['name']); + $this->assertCount(4, $board[1]['columns']); + $this->assertEquals(3, $board[1]['nb_swimlanes']); + $this->assertEquals(4, $board[1]['nb_columns']); + $this->assertEquals(1, $board[1]['nb_tasks']); + $this->assertEquals(2, $board[1]['score']); + + $this->assertSame(0, $board[1]['columns'][0]['score']); + $this->assertSame(0, $board[1]['columns'][1]['score']); + $this->assertSame(2, $board[1]['columns'][2]['score']); + $this->assertSame(0, $board[1]['columns'][3]['score']); + + $this->assertSame(0, $board[1]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][1]['nb_tasks']); + $this->assertSame(1, $board[1]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][3]['nb_tasks']); + + $this->assertEquals('Task 7', $board[1]['columns'][2]['tasks'][0]['title']); + + $this->assertEquals('Swimlane 2', $board[2]['name']); + $this->assertCount(4, $board[2]['columns']); + $this->assertEquals(3, $board[2]['nb_swimlanes']); + $this->assertEquals(4, $board[2]['nb_columns']); + $this->assertEquals(1, $board[2]['nb_tasks']); + $this->assertEquals(3, $board[2]['score']); + + $this->assertSame(0, $board[2]['columns'][0]['score']); + $this->assertSame(0, $board[2]['columns'][1]['score']); + $this->assertSame(3, $board[2]['columns'][2]['score']); + $this->assertSame(0, $board[2]['columns'][3]['score']); + + $this->assertSame(0, $board[2]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[2]['columns'][1]['nb_tasks']); + $this->assertSame(1, $board[2]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[2]['columns'][3]['nb_tasks']); + + $this->assertEquals('Task 8', $board[2]['columns'][2]['tasks'][0]['title']); + } + + public function testFormatWithoutDefaultSwimlane() + { + $projectModel = new ProjectModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertTrue($swimlaneModel->disableDefault(1)); + $this->assertEquals(1, $swimlaneModel->create(array('name' => 'Swimlane 1', 'project_id' => 1))); + $this->assertEquals(2, $swimlaneModel->create(array('name' => 'Swimlane 2', 'project_id' => 1))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task 1', 'project_id' => 1, 'swimlane_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task 2', 'project_id' => 1, 'swimlane_id' => 2, 'column_id' => 2))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task 3', 'project_id' => 1, 'swimlane_id' => 1, 'column_id' => 2, 'score' => 1))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task 4', 'project_id' => 1, 'swimlane_id' => 2, 'column_id' => 1))); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(2, $board); + + $this->assertEquals('Swimlane 1', $board[0]['name']); + $this->assertCount(4, $board[0]['columns']); + $this->assertEquals(2, $board[0]['nb_swimlanes']); + $this->assertEquals(4, $board[0]['nb_columns']); + $this->assertEquals(2, $board[0]['nb_tasks']); + $this->assertEquals(1, $board[0]['score']); + + $this->assertEquals(2, $board[0]['columns'][0]['column_nb_tasks']); + $this->assertEquals(2, $board[0]['columns'][1]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][2]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][3]['column_nb_tasks']); + + $this->assertEquals(0, $board[0]['columns'][0]['column_score']); + $this->assertEquals(1, $board[0]['columns'][1]['column_score']); + $this->assertEquals(0, $board[0]['columns'][2]['column_score']); + $this->assertEquals(0, $board[0]['columns'][3]['column_score']); + + $this->assertSame(0, $board[0]['columns'][0]['score']); + $this->assertSame(1, $board[0]['columns'][1]['score']); + $this->assertSame(0, $board[0]['columns'][2]['score']); + $this->assertSame(0, $board[0]['columns'][3]['score']); + + $this->assertSame(1, $board[0]['columns'][0]['nb_tasks']); + $this->assertSame(1, $board[0]['columns'][1]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][3]['nb_tasks']); + + $this->assertEquals('Task 1', $board[0]['columns'][0]['tasks'][0]['title']); + $this->assertEquals('Task 3', $board[0]['columns'][1]['tasks'][0]['title']); + + $this->assertEquals('Swimlane 2', $board[1]['name']); + $this->assertCount(4, $board[1]['columns']); + $this->assertEquals(2, $board[1]['nb_swimlanes']); + $this->assertEquals(4, $board[1]['nb_columns']); + $this->assertEquals(2, $board[1]['nb_tasks']); + $this->assertEquals(0, $board[1]['score']); + + $this->assertSame(0, $board[1]['columns'][0]['score']); + $this->assertSame(0, $board[1]['columns'][1]['score']); + $this->assertSame(0, $board[1]['columns'][2]['score']); + $this->assertSame(0, $board[1]['columns'][3]['score']); + + $this->assertSame(1, $board[1]['columns'][0]['nb_tasks']); + $this->assertSame(1, $board[1]['columns'][1]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][3]['nb_tasks']); + + $this->assertEquals('Task 4', $board[1]['columns'][0]['tasks'][0]['title']); + $this->assertEquals('Task 2', $board[1]['columns'][1]['tasks'][0]['title']); + } + + public function testFormatWithoutSwimlane() + { + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertTrue($swimlaneModel->disableDefault(1)); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(0, $board); + } + + public function testFormatWithoutColumn() + { + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $columnModel = new ColumnModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertTrue($columnModel->remove(1)); + $this->assertTrue($columnModel->remove(2)); + $this->assertTrue($columnModel->remove(3)); + $this->assertTrue($columnModel->remove(4)); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(0, $board); + } + + public function testFormatWithoutTask() + { + $projectModel = new ProjectModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $swimlaneModel->create(array('name' => 'Swimlane 1', 'project_id' => 1))); + $this->assertEquals(2, $swimlaneModel->create(array('name' => 'Swimlane 2', 'project_id' => 1))); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(3, $board); + + $this->assertEquals('Default swimlane', $board[0]['name']); + $this->assertCount(4, $board[0]['columns']); + $this->assertEquals(3, $board[0]['nb_swimlanes']); + $this->assertEquals(4, $board[0]['nb_columns']); + $this->assertEquals(0, $board[0]['nb_tasks']); + $this->assertEquals(0, $board[0]['score']); + + $this->assertEquals(0, $board[0]['columns'][0]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][1]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][2]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][3]['column_nb_tasks']); + + $this->assertEquals(0, $board[0]['columns'][0]['column_score']); + $this->assertEquals(0, $board[0]['columns'][1]['column_score']); + $this->assertEquals(0, $board[0]['columns'][2]['column_score']); + $this->assertEquals(0, $board[0]['columns'][3]['column_score']); + + $this->assertSame(0, $board[0]['columns'][0]['score']); + $this->assertSame(0, $board[0]['columns'][1]['score']); + $this->assertSame(0, $board[0]['columns'][2]['score']); + $this->assertSame(0, $board[0]['columns'][3]['score']); + + $this->assertSame(0, $board[0]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][1]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][3]['nb_tasks']); + + $this->assertEquals('Swimlane 1', $board[1]['name']); + $this->assertCount(4, $board[1]['columns']); + $this->assertEquals(3, $board[1]['nb_swimlanes']); + $this->assertEquals(4, $board[1]['nb_columns']); + $this->assertEquals(0, $board[1]['nb_tasks']); + $this->assertEquals(0, $board[1]['score']); + + $this->assertSame(0, $board[1]['columns'][0]['score']); + $this->assertSame(0, $board[1]['columns'][1]['score']); + $this->assertSame(0, $board[1]['columns'][2]['score']); + $this->assertSame(0, $board[1]['columns'][3]['score']); + + $this->assertSame(0, $board[1]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][1]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[1]['columns'][3]['nb_tasks']); + + $this->assertEquals('Swimlane 2', $board[2]['name']); + $this->assertCount(4, $board[2]['columns']); + $this->assertEquals(3, $board[2]['nb_swimlanes']); + $this->assertEquals(4, $board[2]['nb_columns']); + $this->assertEquals(0, $board[2]['nb_tasks']); + $this->assertEquals(0, $board[2]['score']); + + $this->assertSame(0, $board[2]['columns'][0]['score']); + $this->assertSame(0, $board[2]['columns'][1]['score']); + $this->assertSame(0, $board[2]['columns'][2]['score']); + $this->assertSame(0, $board[2]['columns'][3]['score']); + + $this->assertSame(0, $board[2]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[2]['columns'][1]['nb_tasks']); + $this->assertSame(0, $board[2]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[2]['columns'][3]['nb_tasks']); + } +} diff --git a/tests/units/FunctionTest.php b/tests/units/FunctionTest.php new file mode 100644 index 000000000..728958455 --- /dev/null +++ b/tests/units/FunctionTest.php @@ -0,0 +1,21 @@ + 123 + ), + array( + 'my_column' => 456.7 + ), + array() + ); + + $this->assertSame(579.7, array_column_sum($input, 'my_column')); + } +} diff --git a/tests/units/Model/BoardTest.php b/tests/units/Model/BoardTest.php index 80587d89c..9f540c631 100644 --- a/tests/units/Model/BoardTest.php +++ b/tests/units/Model/BoardTest.php @@ -3,12 +3,8 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\ProjectModel; -use Kanboard\Model\BoardModel; use Kanboard\Model\ColumnModel; use Kanboard\Model\ConfigModel; -use Kanboard\Model\TaskCreationModel; -use Kanboard\Model\TaskFinderModel; -use Kanboard\Model\SwimlaneModel; class BoardTest extends Base { @@ -45,121 +41,4 @@ class BoardTest extends Base $this->assertEquals('column #1', $columns[5]); $this->assertEquals('column #2', $columns[6]); } - - public function testGetBoard() - { - $p = new ProjectModel($this->container); - $b = new BoardModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); - - $board = $b->getBoard(1); - $this->assertNotEmpty($board); - $this->assertEquals(1, count($board)); - $this->assertEquals(6, count($board[0])); - $this->assertArrayHasKey('name', $board[0]); - $this->assertArrayHasKey('nb_tasks', $board[0]); - $this->assertArrayHasKey('columns', $board[0]); - $this->assertArrayHasKey('tasks', $board[0]['columns'][2]); - $this->assertArrayHasKey('nb_tasks', $board[0]['columns'][2]); - $this->assertArrayHasKey('title', $board[0]['columns'][2]); - $this->assertArrayHasKey('nb_column_tasks', $board[0]['columns'][0]); - $this->assertArrayHasKey('total_score', $board[0]['columns'][0]); - } - - public function testGetBoardWithSwimlane() - { - $b = new BoardModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $s = new SwimlaneModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'test 1'))); - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 3))); - $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 2, 'swimlane_id' => 1))); - $this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 3))); - $this->assertEquals(5, $tc->create(array('title' => 'Task #5', 'project_id' => 1, 'column_id' => 4, 'score' => 2))); - $this->assertEquals(6, $tc->create(array('title' => 'Task #6', 'project_id' => 1, 'column_id' => 4, 'score' => 3, 'swimlane_id' => 1))); - - $board = $b->getBoard(1); - $this->assertNotEmpty($board); - $this->assertEquals(2, count($board)); - $this->assertEquals(6, count($board[0])); - $this->assertArrayHasKey('name', $board[0]); - $this->assertArrayHasKey('nb_tasks', $board[0]); - $this->assertArrayHasKey('columns', $board[0]); - $this->assertArrayHasKey('tasks', $board[0]['columns'][2]); - $this->assertArrayHasKey('nb_tasks', $board[0]['columns'][2]); - $this->assertArrayHasKey('title', $board[0]['columns'][2]); - $this->assertArrayHasKey('nb_column_tasks', $board[0]['columns'][0]); - $this->assertArrayNotHasKey('nb_column_tasks', $board[1]['columns'][0]); - $this->assertArrayNotHasKey('total_score', $board[1]['columns'][0]); - $this->assertArrayHasKey('score', $board[0]['columns'][3]); - $this->assertArrayHasKey('total_score', $board[0]['columns'][3]); - $this->assertEquals(2, $board[0]['columns'][3]['score']); - $this->assertEquals(5, $board[0]['columns'][3]['total_score']); - - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - $this->assertEquals(1, $board[0]['columns'][0]['tasks'][0]['id']); - $this->assertEquals(1, $board[0]['columns'][0]['tasks'][0]['column_id']); - $this->assertEquals(1, $board[0]['columns'][0]['tasks'][0]['position']); - $this->assertEquals(0, $board[0]['columns'][0]['tasks'][0]['swimlane_id']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - $this->assertEquals(2, $board[0]['columns'][2]['tasks'][0]['id']); - $this->assertEquals(3, $board[0]['columns'][2]['tasks'][0]['column_id']); - $this->assertEquals(1, $board[0]['columns'][2]['tasks'][0]['position']); - $this->assertEquals(0, $board[0]['columns'][2]['tasks'][0]['swimlane_id']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - $this->assertEquals(3, $board[1]['columns'][1]['tasks'][0]['id']); - $this->assertEquals(2, $board[1]['columns'][1]['tasks'][0]['column_id']); - $this->assertEquals(1, $board[1]['columns'][1]['tasks'][0]['position']); - $this->assertEquals(1, $board[1]['columns'][1]['tasks'][0]['swimlane_id']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - $this->assertEquals(4, $board[0]['columns'][2]['tasks'][1]['id']); - $this->assertEquals(3, $board[0]['columns'][2]['tasks'][1]['column_id']); - $this->assertEquals(2, $board[0]['columns'][2]['tasks'][1]['position']); - $this->assertEquals(0, $board[0]['columns'][2]['tasks'][1]['swimlane_id']); - - $task = $tf->getById(5); - $this->assertEquals(5, $task['id']); - $this->assertEquals(4, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - $this->assertEquals(5, $board[0]['columns'][3]['tasks'][0]['id']); - $this->assertEquals(4, $board[0]['columns'][3]['tasks'][0]['column_id']); - $this->assertEquals(1, $board[0]['columns'][3]['tasks'][0]['position']); - $this->assertEquals(0, $board[0]['columns'][3]['tasks'][0]['swimlane_id']); - - $task = $tf->getById(6); - $this->assertEquals(6, $task['id']); - $this->assertEquals(4, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - $this->assertEquals(6, $board[1]['columns'][3]['tasks'][0]['id']); - $this->assertEquals(4, $board[1]['columns'][3]['tasks'][0]['column_id']); - $this->assertEquals(1, $board[1]['columns'][3]['tasks'][0]['position']); - $this->assertEquals(1, $board[1]['columns'][3]['tasks'][0]['swimlane_id']); - } } From 700b4e8f0265e4eabd7a7c0eb6a06088d50554fe Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 24 Jun 2016 10:05:45 -0400 Subject: [PATCH 007/139] Associate tags to tasks in BoardFormatter --- app/Formatter/BoardColumnFormatter.php | 15 +++ app/Formatter/BoardFormatter.php | 5 +- app/Formatter/BoardSwimlaneFormatter.php | 15 +++ app/Formatter/BoardTaskFormatter.php | 22 +++- app/Model/TaskTagModel.php | 61 +++++++++-- app/functions.php | 52 +++++++++- tests/units/Formatter/BoardFormatterTest.php | 81 +++++++++++++++ tests/units/FunctionTest.php | 102 +++++++++++++++++++ tests/units/Model/TaskTagModelTest.php | 50 ++++++++- 9 files changed, 386 insertions(+), 17 deletions(-) diff --git a/app/Formatter/BoardColumnFormatter.php b/app/Formatter/BoardColumnFormatter.php index 3d8f6e677..d49a577ac 100644 --- a/app/Formatter/BoardColumnFormatter.php +++ b/app/Formatter/BoardColumnFormatter.php @@ -15,6 +15,7 @@ class BoardColumnFormatter extends BaseFormatter implements FormatterInterface protected $swimlaneId = 0; protected $columns = array(); protected $tasks = array(); + protected $tags = array(); /** * Set swimlaneId @@ -55,6 +56,19 @@ class BoardColumnFormatter extends BaseFormatter implements FormatterInterface return $this; } + /** + * Set tags + * + * @access public + * @param array $tags + * @return $this + */ + public function withTags(array $tags) + { + $this->tags = $tags; + return $this; + } + /** * Apply formatter * @@ -66,6 +80,7 @@ class BoardColumnFormatter extends BaseFormatter implements FormatterInterface foreach ($this->columns as &$column) { $column['tasks'] = BoardTaskFormatter::getInstance($this->container) ->withTasks($this->tasks) + ->withTags($this->tags) ->withSwimlaneId($this->swimlaneId) ->withColumnId($column['id']) ->format(); diff --git a/app/Formatter/BoardFormatter.php b/app/Formatter/BoardFormatter.php index 562a97bc8..350dde6cc 100644 --- a/app/Formatter/BoardFormatter.php +++ b/app/Formatter/BoardFormatter.php @@ -44,12 +44,14 @@ class BoardFormatter extends BaseFormatter implements FormatterInterface { $swimlanes = $this->swimlaneModel->getSwimlanes($this->projectId); $columns = $this->columnModel->getAll($this->projectId); - $tasks = $this->query ->eq(TaskModel::TABLE.'.project_id', $this->projectId) ->asc(TaskModel::TABLE.'.position') ->findAll(); + $task_ids = array_column($tasks, 'id'); + $tags = $this->taskTagModel->getTagsByTasks($task_ids); + if (empty($swimlanes) || empty($columns)) { return array(); } @@ -58,6 +60,7 @@ class BoardFormatter extends BaseFormatter implements FormatterInterface ->withSwimlanes($swimlanes) ->withColumns($columns) ->withTasks($tasks) + ->withTags($tags) ->format(); } } diff --git a/app/Formatter/BoardSwimlaneFormatter.php b/app/Formatter/BoardSwimlaneFormatter.php index 91b4bfd71..c2abb4446 100644 --- a/app/Formatter/BoardSwimlaneFormatter.php +++ b/app/Formatter/BoardSwimlaneFormatter.php @@ -15,6 +15,7 @@ class BoardSwimlaneFormatter extends BaseFormatter implements FormatterInterface protected $swimlanes = array(); protected $columns = array(); protected $tasks = array(); + protected $tags = array(); /** * Set swimlanes @@ -55,6 +56,19 @@ class BoardSwimlaneFormatter extends BaseFormatter implements FormatterInterface return $this; } + /** + * Set tags + * + * @access public + * @param array $tags + * @return $this + */ + public function withTags(array $tags) + { + $this->tags = $tags; + return $this; + } + /** * Apply formatter * @@ -71,6 +85,7 @@ class BoardSwimlaneFormatter extends BaseFormatter implements FormatterInterface ->withSwimlaneId($swimlane['id']) ->withColumns($this->columns) ->withTasks($this->tasks) + ->withTags($this->tags) ->format(); $swimlane['nb_swimlanes'] = $nb_swimlanes; diff --git a/app/Formatter/BoardTaskFormatter.php b/app/Formatter/BoardTaskFormatter.php index d9500710e..3bf171b1a 100644 --- a/app/Formatter/BoardTaskFormatter.php +++ b/app/Formatter/BoardTaskFormatter.php @@ -13,9 +13,23 @@ use Kanboard\Core\Filter\FormatterInterface; class BoardTaskFormatter extends BaseFormatter implements FormatterInterface { protected $tasks = array(); + protected $tags = array(); protected $columnId = 0; protected $swimlaneId = 0; + /** + * Set tags + * + * @access public + * @param array $tags + * @return $this + */ + public function withTags(array $tags) + { + $this->tags = $tags; + return $this; + } + /** * Set tasks * @@ -63,17 +77,19 @@ class BoardTaskFormatter extends BaseFormatter implements FormatterInterface */ public function format() { - return array_values(array_filter($this->tasks, array($this, 'filterTasks'))); + $tasks = array_values(array_filter($this->tasks, array($this, 'filterTasks'))); + array_merge_relation($tasks, $this->tags, 'tags', 'id'); + return $tasks; } /** * Keep only tasks of the given column and swimlane * - * @access public + * @access protected * @param array $task * @return bool */ - public function filterTasks(array $task) + protected function filterTasks(array $task) { return $task['column_id'] == $this->columnId && $task['swimlane_id'] == $this->swimlaneId; } diff --git a/app/Model/TaskTagModel.php b/app/Model/TaskTagModel.php index 74d82539e..3dd1dd887 100644 --- a/app/Model/TaskTagModel.php +++ b/app/Model/TaskTagModel.php @@ -26,7 +26,7 @@ class TaskTagModel extends Base * @param integer $task_id * @return array */ - public function getAll($task_id) + public function getTagsByTask($task_id) { return $this->db->table(TagModel::TABLE) ->columns(TagModel::TABLE.'.id', TagModel::TABLE.'.name') @@ -35,6 +35,28 @@ class TaskTagModel extends Base ->findAll(); } + /** + * Get all tags associated to a list of tasks + * + * @access public + * @param integer[] $task_ids + * @return array + */ + public function getTagsByTasks($task_ids) + { + if (empty($task_ids)) { + return array(); + } + + $tags = $this->db->table(TagModel::TABLE) + ->columns(TagModel::TABLE.'.id', TagModel::TABLE.'.name', self::TABLE.'.task_id') + ->in(self::TABLE.'.task_id', $task_ids) + ->join(self::TABLE, 'tag_id', 'id') + ->findAll(); + + return array_column_index($tags, 'task_id'); + } + /** * Get dictionary of tags * @@ -44,7 +66,7 @@ class TaskTagModel extends Base */ public function getList($task_id) { - $tags = $this->getAll($task_id); + $tags = $this->getTagsByTask($task_id); return array_column($tags, 'name', 'id'); } @@ -61,8 +83,8 @@ class TaskTagModel extends Base { $task_tags = $this->getList($task_id); - return $this->addTags($project_id, $task_id, $task_tags, $tags) && - $this->removeTags($task_id, $task_tags, $tags); + return $this->associateTags($project_id, $task_id, $task_tags, $tags) && + $this->dissociateTags($task_id, $task_tags, $tags); } /** @@ -73,7 +95,7 @@ class TaskTagModel extends Base * @param integer $tag_id * @return boolean */ - public function associate($task_id, $tag_id) + public function associateTag($task_id, $tag_id) { return $this->db->table(self::TABLE)->insert(array( 'task_id' => $task_id, @@ -89,7 +111,7 @@ class TaskTagModel extends Base * @param integer $tag_id * @return boolean */ - public function dissociate($task_id, $tag_id) + public function dissociateTag($task_id, $tag_id) { return $this->db->table(self::TABLE) ->eq('task_id', $task_id) @@ -97,12 +119,22 @@ class TaskTagModel extends Base ->remove(); } - private function addTags($project_id, $task_id, $task_tags, $tags) + /** + * Associate missing tags + * + * @access protected + * @param integer $project_id + * @param integer $task_id + * @param array $task_tags + * @param array $tags + * @return bool + */ + protected function associateTags($project_id, $task_id, $task_tags, $tags) { foreach ($tags as $tag) { $tag_id = $this->tagModel->findOrCreateTag($project_id, $tag); - if (! isset($task_tags[$tag_id]) && ! $this->associate($task_id, $tag_id)) { + if (! isset($task_tags[$tag_id]) && ! $this->associateTag($task_id, $tag_id)) { return false; } } @@ -110,11 +142,20 @@ class TaskTagModel extends Base return true; } - private function removeTags($task_id, $task_tags, $tags) + /** + * Dissociate removed tags + * + * @access protected + * @param integer $task_id + * @param array $task_tags + * @param array $tags + * @return bool + */ + protected function dissociateTags($task_id, $task_tags, $tags) { foreach ($task_tags as $tag_id => $tag) { if (! in_array($tag, $tags)) { - if (! $this->dissociate($task_id, $tag_id)) { + if (! $this->dissociateTag($task_id, $tag_id)) { return false; } } diff --git a/app/functions.php b/app/functions.php index 99431d9e2..eaf33a526 100644 --- a/app/functions.php +++ b/app/functions.php @@ -2,11 +2,61 @@ use Kanboard\Core\Translator; +/** + * Associate another dict to a dict based on a common key + * + * @param array $input + * @param array $relations + * @param string $relation + * @param string $column + */ +function array_merge_relation(array &$input, array &$relations, $relation, $column) +{ + foreach ($input as &$row) { + if (isset($row[$column]) && isset($relations[$row[$column]])) { + $row[$relation] = $relations[$row[$column]]; + } else { + $row[$relation] = array(); + } + } +} + +/** + * Create indexed array from a list of dict + * + * $input = [ + * ['k1' => 1, 'k2' => 2], ['k1' => 3, 'k2' => 4], ['k1' => 2, 'k2' => 5] + * ] + * + * array_column_index($input, 'k1') will returns: + * + * [ + * 1 => [['k1' => 1, 'k2' => 2], ['k1' => 2, 'k2' => 5]], + * 3 => [['k1' => 3, 'k2' => 4]], + * ] + * + * @param array $input + * @param string $column + * @return array + */ +function array_column_index(array &$input, $column) +{ + $result = array(); + + foreach ($input as &$row) { + if (isset($row[$column])) { + $result[$row[$column]][] = $row; + } + } + + return $result; +} + /** * Sum all values from a single column in the input array * * $input = [ - * ['column' => 2'], ['column' => 3'] + * ['column' => 2], ['column' => 3] * ] * * array_column_sum($input, 'column') returns 5 diff --git a/tests/units/Formatter/BoardFormatterTest.php b/tests/units/Formatter/BoardFormatterTest.php index 02b0b5185..c107eaf53 100644 --- a/tests/units/Formatter/BoardFormatterTest.php +++ b/tests/units/Formatter/BoardFormatterTest.php @@ -6,6 +6,7 @@ use Kanboard\Model\ProjectModel; use Kanboard\Model\SwimlaneModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; +use Kanboard\Model\TaskTagModel; require_once __DIR__.'/../Base.php'; @@ -308,4 +309,84 @@ class BoardFormatterTest extends Base $this->assertSame(0, $board[2]['columns'][2]['nb_tasks']); $this->assertSame(0, $board[2]['columns'][3]['nb_tasks']); } + + public function testFormatWithTags() + { + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test2', 'column_id' => 3))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test3'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2'))); + $this->assertTrue($taskTagModel->save(1, 2, array('My tag 3'))); + + $board = BoardFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->withProjectId(1) + ->format(); + + $this->assertCount(1, $board); + + $this->assertEquals('Default swimlane', $board[0]['name']); + $this->assertCount(4, $board[0]['columns']); + $this->assertEquals(1, $board[0]['nb_swimlanes']); + $this->assertEquals(4, $board[0]['nb_columns']); + $this->assertEquals(3, $board[0]['nb_tasks']); + $this->assertEquals(0, $board[0]['score']); + + $this->assertEquals(2, $board[0]['columns'][0]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][1]['column_nb_tasks']); + $this->assertEquals(1, $board[0]['columns'][2]['column_nb_tasks']); + $this->assertEquals(0, $board[0]['columns'][3]['column_nb_tasks']); + + $this->assertEquals(0, $board[0]['columns'][0]['column_score']); + $this->assertEquals(0, $board[0]['columns'][1]['column_score']); + $this->assertEquals(0, $board[0]['columns'][2]['column_score']); + $this->assertEquals(0, $board[0]['columns'][3]['column_score']); + + $this->assertSame(0, $board[0]['columns'][0]['score']); + $this->assertSame(0, $board[0]['columns'][1]['score']); + $this->assertSame(0, $board[0]['columns'][2]['score']); + $this->assertSame(0, $board[0]['columns'][3]['score']); + + $this->assertSame(2, $board[0]['columns'][0]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][1]['nb_tasks']); + $this->assertSame(1, $board[0]['columns'][2]['nb_tasks']); + $this->assertSame(0, $board[0]['columns'][3]['nb_tasks']); + + $this->assertEquals('test1', $board[0]['columns'][0]['tasks'][0]['title']); + $this->assertEquals('test3', $board[0]['columns'][0]['tasks'][1]['title']); + $this->assertEquals('test2', $board[0]['columns'][2]['tasks'][0]['title']); + + $expected = array( + array( + 'id' => 1, + 'name' => 'My tag 1', + 'task_id' => 1, + ), + array( + 'id' => 2, + 'name' => 'My tag 2', + 'task_id' => 1, + ), + ); + + $this->assertEquals($expected, $board[0]['columns'][0]['tasks'][0]['tags']); + $this->assertEquals(array(), $board[0]['columns'][0]['tasks'][1]['tags']); + + $expected = array( + array( + 'id' => 3, + 'name' => 'My tag 3', + 'task_id' => 2, + ), + ); + + $this->assertEquals($expected, $board[0]['columns'][2]['tasks'][0]['tags']); + } } diff --git a/tests/units/FunctionTest.php b/tests/units/FunctionTest.php index 728958455..1c5f971d0 100644 --- a/tests/units/FunctionTest.php +++ b/tests/units/FunctionTest.php @@ -18,4 +18,106 @@ class FunctionTest extends Base $this->assertSame(579.7, array_column_sum($input, 'my_column')); } + + public function testArrayColumnIndex() + { + $input = array( + array( + 'k1' => 11, + 'k2' => 22, + ), + array( + 'k1' => 11, + 'k2' => 55, + ), + array( + 'k1' => 33, + 'k2' => 44, + ), + array() + ); + + $expected = array( + 11 => array( + array( + 'k1' => 11, + 'k2' => 22, + ), + array( + 'k1' => 11, + 'k2' => 55, + ) + ), + 33 => array( + array( + 'k1' => 33, + 'k2' => 44, + ) + ) + ); + + $this->assertSame($expected, array_column_index($input, 'k1')); + } + + public function testArrayMergeRelation() + { + $relations = array( + 88 => array( + 'id' => 123, + 'value' => 'test1', + ), + 99 => array( + 'id' => 456, + 'value' => 'test2', + ), + 55 => array() + ); + + $input = array( + array(), + array( + 'task_id' => 88, + 'title' => 'task1' + ), + array( + 'task_id' => 99, + 'title' => 'task2' + ), + array( + 'task_id' => 11, + 'title' => 'task3' + ) + ); + + $expected = array( + array( + 'my_relation' => array(), + ), + array( + 'task_id' => 88, + 'title' => 'task1', + 'my_relation' => array( + 'id' => 123, + 'value' => 'test1', + ), + ), + array( + 'task_id' => 99, + 'title' => 'task2', + 'my_relation' => array( + 'id' => 456, + 'value' => 'test2', + ), + ), + array( + 'task_id' => 11, + 'title' => 'task3', + 'my_relation' => array(), + ) + ); + + array_merge_relation($input, $relations, 'my_relation', 'task_id'); + + $this->assertSame($expected, $input); + } } diff --git a/tests/units/Model/TaskTagModelTest.php b/tests/units/Model/TaskTagModelTest.php index c08b571fb..819f55b8b 100644 --- a/tests/units/Model/TaskTagModelTest.php +++ b/tests/units/Model/TaskTagModelTest.php @@ -24,7 +24,7 @@ class TaskTagModelTest extends Base $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); - $tags = $taskTagModel->getAll(1); + $tags = $taskTagModel->getTagsByTask(1); $this->assertCount(3, $tags); $this->assertEquals(1, $tags[0]['id']); @@ -38,7 +38,7 @@ class TaskTagModelTest extends Base $this->assertTrue($taskTagModel->save(1, 1, array('My tag 3', 'My tag 1', 'My tag 4'))); - $tags = $taskTagModel->getAll(1); + $tags = $taskTagModel->getTagsByTask(1); $this->assertCount(3, $tags); $this->assertEquals(1, $tags[0]['id']); @@ -64,4 +64,50 @@ class TaskTagModelTest extends Base $this->assertEquals('My tag 4', $tags[3]['name']); $this->assertEquals(1, $tags[3]['project_id']); } + + public function testGetTagsForTasks() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test2'))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test3'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + $this->assertTrue($taskTagModel->save(1, 2, array('My tag 3'))); + + $tags = $taskTagModel->getTagsByTasks(array(1, 2, 3)); + + $expected = array( + 1 => array( + array( + 'id' => 1, + 'name' => 'My tag 1', + 'task_id' => 1 + ), + array( + 'id' => 2, + 'name' => 'My tag 2', + 'task_id' => 1 + ), + array( + 'id' => 3, + 'name' => 'My tag 3', + 'task_id' => 1 + ), + ), + 2 => array( + array( + 'id' => 3, + 'name' => 'My tag 3', + 'task_id' => 2, + ) + ) + ); + + $this->assertEquals($expected, $tags); + } } From b2e92480c29acb15586bc8ea305c8416927a667c Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 24 Jun 2016 11:40:58 -0400 Subject: [PATCH 008/139] Added filter class for tags --- app/Filter/TaskTagFilter.php | 74 ++++++++++++++ app/Model/TaskTagModel.php | 20 ++-- app/ServiceProvider/FilterProvider.php | 4 + doc/search.markdown | 6 ++ tests/units/Base.php | 4 +- tests/units/Core/Filter/LexerTest.php | 12 +++ tests/units/Filter/TaskTagFilterTest.php | 121 +++++++++++++++++++++++ tests/units/Model/TaskTagModelTest.php | 14 +++ 8 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 app/Filter/TaskTagFilter.php create mode 100644 tests/units/Filter/TaskTagFilterTest.php diff --git a/app/Filter/TaskTagFilter.php b/app/Filter/TaskTagFilter.php new file mode 100644 index 000000000..01b6f6256 --- /dev/null +++ b/app/Filter/TaskTagFilter.php @@ -0,0 +1,74 @@ +db = $db; + return $this; + } + + /** + * Apply filter + * + * @access public + * @return FilterInterface + */ + public function apply() + { + $task_ids = $this->db + ->table(TagModel::TABLE) + ->ilike(TagModel::TABLE.'.name', $this->value) + ->asc(TagModel::TABLE.'.project_id') + ->join(TaskTagModel::TABLE, 'tag_id', 'id') + ->findAllByColumn(TaskTagModel::TABLE.'.task_id'); + + if (empty($task_ids)) { + $task_ids = array(-1); + } + + $this->query->in(TaskModel::TABLE.'.id', $task_ids); + + return $this; + } +} diff --git a/app/Model/TaskTagModel.php b/app/Model/TaskTagModel.php index 3dd1dd887..91dfd2241 100644 --- a/app/Model/TaskTagModel.php +++ b/app/Model/TaskTagModel.php @@ -74,9 +74,9 @@ class TaskTagModel extends Base * Add or update a list of tags to a task * * @access public - * @param integer $project_id - * @param integer $task_id - * @param string[] $tags + * @param integer $project_id + * @param integer $task_id + * @param string[] $tags * @return boolean */ public function save($project_id, $task_id, array $tags) @@ -123,10 +123,10 @@ class TaskTagModel extends Base * Associate missing tags * * @access protected - * @param integer $project_id - * @param integer $task_id - * @param array $task_tags - * @param array $tags + * @param integer $project_id + * @param integer $task_id + * @param array $task_tags + * @param string[] $tags * @return bool */ protected function associateTags($project_id, $task_id, $task_tags, $tags) @@ -146,9 +146,9 @@ class TaskTagModel extends Base * Dissociate removed tags * * @access protected - * @param integer $task_id - * @param array $task_tags - * @param array $tags + * @param integer $task_id + * @param array $task_tags + * @param string[] $tags * @return bool */ protected function dissociateTags($task_id, $task_tags, $tags) diff --git a/app/ServiceProvider/FilterProvider.php b/app/ServiceProvider/FilterProvider.php index cdef9ed8d..20281a096 100644 --- a/app/ServiceProvider/FilterProvider.php +++ b/app/ServiceProvider/FilterProvider.php @@ -26,6 +26,7 @@ use Kanboard\Filter\TaskReferenceFilter; use Kanboard\Filter\TaskStatusFilter; use Kanboard\Filter\TaskSubtaskAssigneeFilter; use Kanboard\Filter\TaskSwimlaneFilter; +use Kanboard\Filter\TaskTagFilter; use Kanboard\Filter\TaskTitleFilter; use Kanboard\Model\ProjectModel; use Kanboard\Model\ProjectGroupRoleModel; @@ -163,6 +164,9 @@ class FilterProvider implements ServiceProviderInterface ->setDatabase($c['db']) ) ->withFilter(new TaskSwimlaneFilter()) + ->withFilter(TaskTagFilter::getInstance() + ->setDatabase($c['db']) + ) ->withFilter(new TaskTitleFilter(), true) ; diff --git a/doc/search.markdown b/doc/search.markdown index 37bb86251..ab8e0b5a3 100644 --- a/doc/search.markdown +++ b/doc/search.markdown @@ -152,6 +152,12 @@ Attribute: **comment** - Find comments that contains this title: `comment:"My comment message"` +### Search by tags + +Attribute: **tag** + +- Example: `tag:"My tag"` + Activity stream search ---------------------- diff --git a/tests/units/Base.php b/tests/units/Base.php index f7bee241a..9dbfb2805 100644 --- a/tests/units/Base.php +++ b/tests/units/Base.php @@ -48,8 +48,8 @@ abstract class Base extends PHPUnit_Framework_TestCase new Stopwatch ); - $this->container['db']->logQueries = true; - $this->container['logger'] = new Logger; + $this->container['db']->getStatementHandler()->withLogging(); + $this->container['logger'] = new Logger(); $this->container['httpClient'] = $this ->getMockBuilder('\Kanboard\Core\Http\Client') diff --git a/tests/units/Core/Filter/LexerTest.php b/tests/units/Core/Filter/LexerTest.php index c72231c4c..b777531df 100644 --- a/tests/units/Core/Filter/LexerTest.php +++ b/tests/units/Core/Filter/LexerTest.php @@ -202,4 +202,16 @@ class LexerTest extends Base $this->assertSame($expected, $lexer->tokenize('६Δↈ五一')); } + + public function testTokenizeWithMultipleValues() + { + $lexer = new Lexer(); + $lexer->addToken("/^(tag:)/", 'T_TAG'); + + $expected = array( + 'T_TAG' => array('tag 1', 'tag2'), + ); + + $this->assertSame($expected, $lexer->tokenize('tag:"tag 1" tag:tag2')); + } } diff --git a/tests/units/Filter/TaskTagFilterTest.php b/tests/units/Filter/TaskTagFilterTest.php new file mode 100644 index 000000000..a36d3475d --- /dev/null +++ b/tests/units/Filter/TaskTagFilterTest.php @@ -0,0 +1,121 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + $query = $taskFinderModel->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test2'))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test3'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + $this->assertTrue($taskTagModel->save(1, 2, array('My tag 3'))); + + $filter = new TaskTagFilter(); + $filter->setDatabase($this->container['db']); + $filter->withQuery($query); + $filter->withValue('my tag 3'); + $filter->apply(); + + $tasks = $query->findAll(); + $this->assertCount(2, $tasks); + $this->assertEquals('test1', $tasks[0]['title']); + $this->assertEquals('test2', $tasks[1]['title']); + } + + public function testWithSingleMatch() + { + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + $query = $taskFinderModel->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test2'))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test3'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + $this->assertTrue($taskTagModel->save(1, 2, array('My tag 3'))); + + $filter = new TaskTagFilter(); + $filter->setDatabase($this->container['db']); + $filter->withQuery($query); + $filter->withValue('my tag 2'); + $filter->apply(); + + $tasks = $query->findAll(); + $this->assertCount(1, $tasks); + $this->assertEquals('test1', $tasks[0]['title']); + } + + public function testWithNoMatch() + { + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + $query = $taskFinderModel->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test2'))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test3'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + $this->assertTrue($taskTagModel->save(1, 2, array('My tag 3'))); + + $filter = new TaskTagFilter(); + $filter->setDatabase($this->container['db']); + $filter->withQuery($query); + $filter->withValue('my tag 42'); + $filter->apply(); + + $tasks = $query->findAll(); + $this->assertCount(0, $tasks); + } + + public function testWithSameTagInMultipleProjects() + { + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + $query = $taskFinderModel->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 2, 'title' => 'test2'))); + + $this->assertTrue($taskTagModel->save(1, 1, array('My tag'))); + $this->assertTrue($taskTagModel->save(2, 2, array('My tag'))); + + $filter = new TaskTagFilter(); + $filter->setDatabase($this->container['db']); + $filter->withQuery($query); + $filter->withValue('my tag'); + $filter->apply(); + + $tasks = $query->findAll(); + $this->assertCount(2, $tasks); + $this->assertEquals('test1', $tasks[0]['title']); + $this->assertEquals('test2', $tasks[1]['title']); + } +} diff --git a/tests/units/Model/TaskTagModelTest.php b/tests/units/Model/TaskTagModelTest.php index 819f55b8b..73bbeac1d 100644 --- a/tests/units/Model/TaskTagModelTest.php +++ b/tests/units/Model/TaskTagModelTest.php @@ -110,4 +110,18 @@ class TaskTagModelTest extends Base $this->assertEquals($expected, $tags); } + + public function testGetTagsForTasksWithEmptyList() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskTagModel = new TaskTagModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test1'))); + $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3'))); + + $tags = $taskTagModel->getTagsByTasks(array()); + $this->assertEquals(array(), $tags); + } } From 18cb7ad0a4a96be63030f5207b74a195c8b6cd6c Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 24 Jun 2016 15:43:34 -0400 Subject: [PATCH 009/139] Expose tags to the user interface (first prototype) --- app/Controller/TaskModificationController.php | 1 + app/Controller/TaskViewController.php | 2 + app/Helper/TaskHelper.php | 28 ++ app/Model/TagModel.php | 18 ++ app/Model/TaskCreationModel.php | 14 +- app/Model/TaskModificationModel.php | 5 + app/Template/board/task_footer.php | 10 + app/Template/task/details.php | 279 +++++++++--------- app/Template/task/public.php | 7 +- app/Template/task/show.php | 1 + app/Template/task_creation/show.php | 4 +- app/Template/task_gantt_creation/show.php | 5 +- app/Template/task_modification/edit_task.php | 4 +- assets/css/app.min.css | 2 +- assets/css/print.min.css | 2 +- assets/css/src/board.css | 13 - assets/css/src/form.css | 10 +- assets/css/src/task.css | 31 ++ assets/css/vendor.min.css | 2 + assets/js/app.min.js | 4 +- assets/js/src/App.js | 7 + assets/js/src/Popover.js | 1 + assets/js/vendor.min.js | 3 + bower.json | 3 +- gulpfile.js | 2 + tests/units/Filter/TaskTagFilterTest.php | 5 +- 26 files changed, 296 insertions(+), 167 deletions(-) diff --git a/app/Controller/TaskModificationController.php b/app/Controller/TaskModificationController.php index f9c63c124..d55a7193c 100644 --- a/app/Controller/TaskModificationController.php +++ b/app/Controller/TaskModificationController.php @@ -98,6 +98,7 @@ class TaskModificationController extends BaseController 'values' => $values, 'errors' => $errors, 'task' => $task, + 'tags' => $this->taskTagModel->getList($task['id']), 'users_list' => $this->projectUserRoleModel->getAssignableUsersList($task['project_id']), 'colors_list' => $this->colorModel->getList(), 'categories_list' => $this->categoryModel->getList($task['project_id']), diff --git a/app/Controller/TaskViewController.php b/app/Controller/TaskViewController.php index bd1e86aeb..f40f8bea6 100644 --- a/app/Controller/TaskViewController.php +++ b/app/Controller/TaskViewController.php @@ -45,6 +45,7 @@ class TaskViewController extends BaseController 'task' => $task, 'columns_list' => $this->columnModel->getList($task['project_id']), 'colors_list' => $this->colorModel->getList(), + 'tags' => $this->taskTagModel->getList($task['id']), 'title' => $task['title'], 'no_layout' => true, 'auto_refresh' => true, @@ -82,6 +83,7 @@ class TaskViewController extends BaseController 'internal_links' => $this->taskLinkModel->getAllGroupedByLabel($task['id']), 'external_links' => $this->taskExternalLinkModel->getAll($task['id']), 'link_label_list' => $this->linkModel->getList(0, false), + 'tags' => $this->taskTagModel->getList($task['id']), ))); } diff --git a/app/Helper/TaskHelper.php b/app/Helper/TaskHelper.php index e33438d6e..f272059d7 100644 --- a/app/Helper/TaskHelper.php +++ b/app/Helper/TaskHelper.php @@ -40,6 +40,34 @@ class TaskHelper extends Base return $this->taskModel->getRecurrenceBasedateList(); } + public function selectTitle(array $values, array $errors) + { + $html = $this->helper->form->label(t('Title'), 'title'); + $html .= $this->helper->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large'); + return $html; + } + + public function selectTags(array $project, array $tags = array()) + { + $options = $this->tagModel->getAssignableList($project['id']); + + $html = $this->helper->form->label(t('Tags'), 'tags[]'); + $html .= ''; + + return $html; + } + public function selectAssignee(array $users, array $values, array $errors = array(), array $attributes = array()) { $attributes = array_merge(array('tabindex="3"'), $attributes); diff --git a/app/Model/TagModel.php b/app/Model/TagModel.php index 1be05a66d..8eb5e5baf 100644 --- a/app/Model/TagModel.php +++ b/app/Model/TagModel.php @@ -42,6 +42,24 @@ class TagModel extends Base return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('name')->findAll(); } + /** + * Get assignable tags for a project + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAssignableList($project_id) + { + return $this->db->hashtable(self::TABLE) + ->beginOr() + ->eq('project_id', $project_id) + ->eq('project_id', 0) + ->closeOr() + ->asc('name') + ->getAll('id', 'name'); + } + /** * Get one tag * diff --git a/app/Model/TaskCreationModel.php b/app/Model/TaskCreationModel.php index 3800f831c..fa2d32c63 100644 --- a/app/Model/TaskCreationModel.php +++ b/app/Model/TaskCreationModel.php @@ -22,11 +22,13 @@ class TaskCreationModel extends Base */ public function create(array $values) { - if (! $this->projectModel->exists($values['project_id'])) { - return 0; - } - $position = empty($values['position']) ? 0 : $values['position']; + $tags = array(); + + if (isset($values['tags'])) { + $tags = $values['tags']; + unset($values['tags']); + } $this->prepare($values); $task_id = $this->db->table(TaskModel::TABLE)->persist($values); @@ -36,6 +38,10 @@ class TaskCreationModel extends Base $this->taskPositionModel->movePosition($values['project_id'], $task_id, $values['column_id'], $position, $values['swimlane_id'], false); } + if (! empty($tags)) { + $this->taskTagModel->save($values['project_id'], $task_id, $tags); + } + $this->fireEvents($task_id, $values); } diff --git a/app/Model/TaskModificationModel.php b/app/Model/TaskModificationModel.php index 762af2c51..0fc3617ee 100644 --- a/app/Model/TaskModificationModel.php +++ b/app/Model/TaskModificationModel.php @@ -85,6 +85,11 @@ class TaskModificationModel extends Base */ public function prepare(array &$values) { + if (isset($values['tags'])) { + $this->taskTagModel->save($values['project_id'], $values['id'], $values['tags']); + unset($values['tags']); + } + $values = $this->dateParser->convert($values, array('date_due')); $values = $this->dateParser->convert($values, array('date_started'), true); diff --git a/app/Template/board/task_footer.php b/app/Template/board/task_footer.php index f6cbff70d..37d13605a 100644 --- a/app/Template/board/task_footer.php +++ b/app/Template/board/task_footer.php @@ -18,6 +18,16 @@ + +
+
    + +
  • text->e($tag['name']) ?>
  • + +
+
+ +
diff --git a/app/Template/task/details.php b/app/Template/task/details.php index fe2bba677..695957f93 100644 --- a/app/Template/task/details.php +++ b/app/Template/task/details.php @@ -4,146 +4,157 @@ hook->render('template:task:details:top', array('task' => $task)) ?>
-
-
    -
  • - - - - - - +
    +
    +
      +
    • + + + + + + + + +
    • +
    • + +
    • + +
    • + text->e($task['reference']) ?> +
    • - - -
    • - -
    • - -
    • - text->e($task['reference']) ?> -
    • - - -
    • - text->e($task['score']) ?> -
    • - - -
    • - - url->link(t('Public link'), 'TaskViewController', 'readonly', array('task_id' => $task['id'], 'token' => $project['token']), false, '', '', true) ?> -
    • - - -
    • - - url->link(t('Back to the board'), 'BoardViewController', 'readonly', array('token' => $project['token'])) ?> -
    • - -
    • - - hook->render('template:task:details:first-column', array('task' => $task)) ?> -
    -
    -
    -
      - -
    • - - text->e($task['category_name']) ?> -
    • - - -
    • - - text->e($task['swimlane_name']) ?> -
    • - -
    • - - text->e($task['column_title']) ?> -
    • -
    • - - -
    • - - hook->render('template:task:details:second-column', array('task' => $task)) ?> -
    -
    -
    -
      -
    • - - - - text->e($task['assignee_name'] ?: $task['assignee_username']) ?> - - + +
    • + text->e($task['score']) ?> +
    • - - - -
    • - - text->e($task['creator_name'] ?: $task['creator_username']) ?> + +
    • + + url->link(t('Public link'), 'TaskViewController', 'readonly', array('task_id' => $task['id'], 'token' => $project['token']), false, '', '', true) ?>
    • - - -
    • - - dt->date($task['date_due']) ?> -
    • - - -
    • - - -
    • - - -
    • - - -
    • - + + +
    • + + url->link(t('Back to the board'), 'BoardViewController', 'readonly', array('token' => $project['token'])) ?> +
    • + +
    • - hook->render('template:task:details:third-column', array('task' => $task)) ?> -
    -
    -
    -
      -
    • - - dt->datetime($task['date_creation']) ?> -
    • -
    • - - dt->datetime($task['date_modification']) ?> -
    • - -
    • - - dt->datetime($task['date_completed']) ?> -
    • - - -
    • - - dt->datetime($task['date_started']) ?> -
    • - - -
    • - - dt->datetime($task['date_moved']) ?> -
    • - + hook->render('template:task:details:first-column', array('task' => $task)) ?> +
    +
    +
    +
      + +
    • + + text->e($task['category_name']) ?> +
    • + + +
    • + + text->e($task['swimlane_name']) ?> +
    • + +
    • + + text->e($task['column_title']) ?> +
    • +
    • + + +
    • - hook->render('template:task:details:fourth-column', array('task' => $task)) ?> -
    + hook->render('template:task:details:second-column', array('task' => $task)) ?> +
+
+
+
    +
  • + + + + text->e($task['assignee_name'] ?: $task['assignee_username']) ?> + + + + +
  • + +
  • + + text->e($task['creator_name'] ?: $task['creator_username']) ?> +
  • + + +
  • + + dt->date($task['date_due']) ?> +
  • + + +
  • + + +
  • + + +
  • + + +
  • + + + hook->render('template:task:details:third-column', array('task' => $task)) ?> +
+
+
+
    +
  • + + dt->datetime($task['date_creation']) ?> +
  • +
  • + + dt->datetime($task['date_modification']) ?> +
  • + +
  • + + dt->datetime($task['date_completed']) ?> +
  • + + +
  • + + dt->datetime($task['date_started']) ?> +
  • + + +
  • + + dt->datetime($task['date_moved']) ?> +
  • + + + hook->render('template:task:details:fourth-column', array('task' => $task)) ?> +
+
+ +
+
    + +
  • text->e($tag) ?>
  • + +
+
+
diff --git a/app/Template/task/public.php b/app/Template/task/public.php index 947821635..b8405ff7f 100644 --- a/app/Template/task/public.php +++ b/app/Template/task/public.php @@ -1,5 +1,10 @@
- render('task/details', array('task' => $task, 'project' => $project, 'editable' => false)) ?> + render('task/details', array( + 'task' => $task, + 'tags' => $tags, + 'project' => $project, + 'editable' => false, + )) ?> render('task/description', array( 'task' => $task, diff --git a/app/Template/task/show.php b/app/Template/task/show.php index 2b54eea85..807867152 100644 --- a/app/Template/task/show.php +++ b/app/Template/task/show.php @@ -2,6 +2,7 @@ render('task/details', array( 'task' => $task, + 'tags' => $tags, 'project' => $project, 'editable' => $this->user->hasProjectAccess('TaskModificationController', 'edit', $project['id']), )) ?> diff --git a/app/Template/task_creation/show.php b/app/Template/task_creation/show.php index 7bebbfe9a..cd752ebad 100644 --- a/app/Template/task_creation/show.php +++ b/app/Template/task_creation/show.php @@ -7,8 +7,7 @@ form->csrf() ?>
- form->label(t('Title'), 'title') ?> - form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?> + task->selectTitle($values, $errors) ?> form->label(t('Description'), 'description') ?> form->textarea( @@ -23,6 +22,7 @@ 'markdown-editor' ) ?> + task->selectTags($project) ?> render('task/color_picker', array('colors_list' => $colors_list, 'values' => $values)) ?> diff --git a/app/Template/task_gantt_creation/show.php b/app/Template/task_gantt_creation/show.php index 683bc8c88..d1bfa67cd 100644 --- a/app/Template/task_gantt_creation/show.php +++ b/app/Template/task_gantt_creation/show.php @@ -8,12 +8,11 @@ form->hidden('position', $values) ?>
- form->label(t('Title'), 'title') ?> - form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?> + task->selectTitle($values, $errors) ?> form->label(t('Description'), 'description') ?> form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"'), 'markdown-editor') ?> - + task->selectTags($project) ?> render('task/color_picker', array('colors_list' => $colors_list, 'values' => $values)) ?>
diff --git a/app/Template/task_modification/edit_task.php b/app/Template/task_modification/edit_task.php index 0707fd9a0..d8f18743c 100644 --- a/app/Template/task_modification/edit_task.php +++ b/app/Template/task_modification/edit_task.php @@ -7,8 +7,8 @@ form->hidden('project_id', $values) ?>
- form->label(t('Title'), 'title') ?> - form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"')) ?> + task->selectTitle($values, $errors) ?> + task->selectTags($project, $tags) ?> task->selectAssignee($users_list, $values, $errors) ?> task->selectCategory($categories_list, $values, $errors) ?> task->selectPriority($project, $values) ?> diff --git a/assets/css/app.min.css b/assets/css/app.min.css index 7d0939524..49630a001 100644 --- a/assets/css/app.min.css +++ b/assets/css/app.min.css @@ -1 +1 @@ -a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.color-square,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}textarea{border:1px solid #ccc;width:400px;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}.form-column{float:left;margin-right:3%;max-width:50%;min-width:40%}.form-column ul{margin-top:15px}.form-clear{clear:both;padding-top:20px;padding-bottom:10px}.form-login{width:350px;margin:8% auto 0}.form-column li,.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,.9)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file +a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.color-square,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:3px;margin-bottom:10px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}.form-column{float:left;margin-right:3%;max-width:50%;min-width:40%}.form-column ul{margin-top:15px}.form-clear{clear:both;padding-top:20px;padding-bottom:10px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,.9)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file diff --git a/assets/css/print.min.css b/assets/css/print.min.css index 5041beb8b..a5b4407eb 100644 --- a/assets/css/print.min.css +++ b/assets/css/print.min.css @@ -1 +1 @@ -a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.color-square,.task-board-change-assignee{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-board-avatars{text-align:right;float:right}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px;display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,.9)}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file +a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.color-square,.task-board-change-assignee{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{min-height:35px}.color-square{display:inline-block;width:30px;height:30px;margin-right:5px;margin-bottom:5px;border:1px solid #000}.color-square:hover{border-style:dotted}div.color-square-selected{border-width:2px;width:28px;height:28px;box-shadow:3px 2px 10px 0 rgba(180,180,180,.9)}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file diff --git a/assets/css/src/board.css b/assets/css/src/board.css index 586093b82..95e041089 100644 --- a/assets/css/src/board.css +++ b/assets/css/src/board.css @@ -157,16 +157,3 @@ div.draggable-item-selected { float: left; padding-right: 5px; } - -/* board saving state */ -.task-board-saving-state { - opacity: 0.3; -} - -.task-board-saving-icon { - position: absolute; - margin: auto; - width: 100%; - text-align: center; - color: #000; -} diff --git a/assets/css/src/form.css b/assets/css/src/form.css index fd778c88e..27c19835c 100644 --- a/assets/css/src/form.css +++ b/assets/css/src/form.css @@ -61,6 +61,15 @@ select:focus { outline: 0; } +.tag-autocomplete { + width: 400px; +} + +span.select2-container { + margin-top: 3px; + margin-bottom: 10px; +} + ::-webkit-input-placeholder { color: #ddd; padding-top: 2px; @@ -169,7 +178,6 @@ input.form-input-large { margin-top: 8%; } -.form-column li, .form-login li { margin-left: 25px; line-height: 25px; diff --git a/assets/css/src/task.css b/assets/css/src/task.css index 2a5f1e97f..c134809c1 100644 --- a/assets/css/src/task.css +++ b/assets/css/src/task.css @@ -53,6 +53,19 @@ div.task-board-status-closed { text-overflow: ellipsis; } +/* board saving state */ +.task-board-saving-state { + opacity: 0.3; +} + +.task-board-saving-icon { + position: absolute; + margin: auto; + width: 100%; + text-align: center; + color: #000; +} + /* title one the card */ .task-board-title { font-size: 1.15em; @@ -83,6 +96,21 @@ div.task-board-status-closed { opacity: 0.6; } +/* tags list */ +.task-tags li { + display: inline; + margin: 0; + margin-right: 4px; + padding: 2px; + color: #666; + border: 1px solid #666; + border-radius: 2px; +} + +.task-summary-container .task-tags { + margin-top: 10px; +} + /* avatars on the card */ .task-board-avatars { text-align: right; @@ -181,6 +209,9 @@ span.task-board-age-column { border: 2px solid #000; border-radius: 8px; padding: 15px; +} + +.task-summary-columns { display: -webkit-flex; display: flex; -webkit-flex-direction: row; diff --git a/assets/css/vendor.min.css b/assets/css/vendor.min.css index d5b238ded..edd97352d 100644 --- a/assets/css/vendor.min.css +++ b/assets/css/vendor.min.css @@ -459,6 +459,8 @@ This file is generated by `grunt build`, do not edit it by hand. } /* @end */ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;height:1px !important;margin:-1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__placeholder{color:#999;margin-top:5px;float:left}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} + /*! * FullCalendar v2.7.1 Stylesheet * Docs & License: http://fullcalendar.io/ diff --git a/assets/js/app.min.js b/assets/js/app.min.js index 075b6489e..7f894359b 100644 --- a/assets/js/app.min.js +++ b/assets/js/app.min.js @@ -1,2 +1,2 @@ -"use strict";var Kanboard={};Kanboard.Accordion=function(t){this.app=t},Kanboard.Accordion.prototype.listen=function(){$(document).on("click",".accordion-toggle",function(t){var e=$(this).parents(".accordion-section");t.preventDefault(),e.hasClass("accordion-collapsed")?(e.find(".accordion-content").show(),e.removeClass("accordion-collapsed")):(e.find(".accordion-content").hide(),e.addClass("accordion-collapsed"))})},Kanboard.App=function(){this.controllers={}},Kanboard.App.prototype.get=function(t){return this.controllers[t]},Kanboard.App.prototype.execute=function(){for(var t in Kanboard)if("App"!==t){var e=new Kanboard[t](this);this.controllers[t]=e,"function"==typeof e.execute&&e.execute(),"function"==typeof e.listen&&e.listen(),"function"==typeof e.focus&&e.focus(),"function"==typeof e.keyboardShortcuts&&e.keyboardShortcuts()}this.focus(),this.chosen(),this.keyboardShortcuts(),this.datePicker(),this.autoComplete()},Kanboard.App.prototype.keyboardShortcuts=function(){var t=this;Mousetrap.bindGlobal("mod+enter",function(){var e=$("form");1==e.length?e.submit():e.length>1&&("INPUT"===document.activeElement.tagName||"TEXTAREA"===document.activeElement.tagName?$(document.activeElement).parents("form").submit():t.get("Popover").isOpen()&&$("#popover-container form").submit())}),Mousetrap.bind("b",function(t){t.preventDefault(),$("#board-selector").trigger("chosen:open")}),Mousetrap.bindGlobal("esc",function(){t.get("Popover").close(),t.get("Dropdown").close()}),Mousetrap.bind("?",function(){t.get("Popover").open($("body").data("keyboard-shortcut-url"))})},Kanboard.App.prototype.focus=function(){$(document).on("focus",".auto-select",function(){$(this).select()}),$(document).on("mouseup",".auto-select",function(t){t.preventDefault()})},Kanboard.App.prototype.chosen=function(){$(".chosen-select").each(function(){var t=$(this).data("search-threshold");void 0===t&&(t=10),$(this).chosen({width:"180px",no_results_text:$(this).data("notfound"),disable_search_threshold:t})}),$(".select-auto-redirect").change(function(){var t=new RegExp($(this).data("redirect-regex"),"g");window.location=$(this).data("redirect-url").replace(t,$(this).val())})},Kanboard.App.prototype.datePicker=function(){var t=$("body"),e=t.data("js-date-format"),a=t.data("js-time-format"),o=t.data("js-lang");$.datepicker.setDefaults($.datepicker.regional[o]),$.timepicker.setDefaults($.timepicker.regional[o]),$(".form-date").datepicker({showOtherMonths:!0,selectOtherMonths:!0,dateFormat:e,constrainInput:!1}),$(".form-datetime").datetimepicker({dateFormat:e,timeFormat:a,constrainInput:!1})},Kanboard.App.prototype.autoComplete=function(){$(".autocomplete").each(function(){var t=$(this),e=t.data("dst-field"),a=t.data("dst-extra-field");""==$("#form-"+e).val()&&t.parent().find("button[type=submit]").attr("disabled","disabled"),t.autocomplete({source:t.data("search-url"),minLength:1,select:function(o,n){$("input[name="+e+"]").val(n.item.id),a&&$("input[name="+a+"]").val(n.item[a]),t.parent().find("button[type=submit]").removeAttr("disabled")}})})},Kanboard.App.prototype.hasId=function(t){return!!document.getElementById(t)},Kanboard.App.prototype.showLoadingIcon=function(){$("body").append(' ')},Kanboard.App.prototype.hideLoadingIcon=function(){$("#app-loading-icon").remove()},Kanboard.App.prototype.formatDuration=function(t){return t>=86400?Math.round(t/86400)+"d":t>=3600?Math.round(t/3600)+"h":t>=60?Math.round(t/60)+"m":t+"s"},Kanboard.App.prototype.isVisible=function(){var t="";return"undefined"!=typeof document.hidden?t="visibilityState":"undefined"!=typeof document.mozHidden?t="mozVisibilityState":"undefined"!=typeof document.msHidden?t="msVisibilityState":"undefined"!=typeof document.webkitHidden&&(t="webkitVisibilityState"),""!=t?"visible"==document[t]:!0},Kanboard.AvgTimeColumnChart=function(t){this.app=t},Kanboard.AvgTimeColumnChart.prototype.execute=function(){this.app.hasId("analytic-avg-time-column")&&this.show()},Kanboard.AvgTimeColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=[t.data("label")],o=[];for(var n in e)a.push(e[n].average),o.push(e[n].title);c3.generate({data:{columns:[a],type:"bar"},bar:{width:{ratio:.5}},axis:{x:{type:"category",categories:o},y:{tick:{format:this.app.formatDuration}}},legend:{show:!1}})},Kanboard.BoardCollapsedMode=function(t){this.app=t},Kanboard.BoardCollapsedMode.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("s",function(){t.toggle()})},Kanboard.BoardCollapsedMode.prototype.toggle=function(){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$('.filter-display-mode:not([style="display: none;"]) a').attr("href"),success:function(e){$(".filter-display-mode").toggle(),t.app.get("BoardDragAndDrop").refresh(e)}})},Kanboard.BoardColumnScrolling=function(t){this.app=t},Kanboard.BoardColumnScrolling.prototype.execute=function(){this.app.hasId("board")&&(this.render(),$(window).on("load",this.render),$(window).resize(this.render))},Kanboard.BoardColumnScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-height",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardColumnScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnScrolling.prototype.toggle=function(){var t=localStorage.getItem("column_scroll");void 0==t&&(t=1),localStorage.setItem("column_scroll",0==t?1:0),this.render()},Kanboard.BoardColumnScrolling.prototype.render=function(){var t=$(".board-task-list"),e=$(".board-rotation-wrapper"),a=$(".filter-max-height"),o=$(".filter-min-height");if(0==localStorage.getItem("column_scroll")){var n=80;a.show(),o.hide(),e.css("min-height",""),t.each(function(){var t=$(this).height();t>n&&(n=t)}),t.css("min-height",n),t.css("height","")}else if(a.hide(),o.show(),$(".board-swimlane").length>1)t.each(function(){$(this).height()>500?$(this).css("height",500):($(this).css("min-height",320),e.css("min-height",320))});else{var n=$(window).height()-170;t.css("height",n),e.css("min-height",n)}},Kanboard.BoardColumnView=function(t){this.app=t},Kanboard.BoardColumnView.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardColumnView.prototype.listen=function(){var t=this;$(document).on("click",".board-toggle-column-view",function(){t.toggle($(this).data("column-id"))})},Kanboard.BoardColumnView.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnView.prototype.render=function(){var t=this;$(".board-column-header").each(function(){var e=$(this).data("column-id");localStorage.getItem("hidden_column_"+e)&&t.hideColumn(e)})},Kanboard.BoardColumnView.prototype.toggle=function(t){localStorage.getItem("hidden_column_"+t)?this.showColumn(t):this.hideColumn(t)},Kanboard.BoardColumnView.prototype.hideColumn=function(t){$(".board-column-"+t+" .board-column-expanded").hide(),$(".board-column-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t+" .board-column-expanded").hide(),$(".board-column-header-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t).each(function(){$(this).removeClass("board-column-compact"),$(this).addClass("board-column-header-collapsed")}),$(".board-column-"+t).each(function(){$(this).addClass("board-column-task-collapsed")}),$(".board-column-"+t+" .board-rotation").each(function(){$(this).css("width",$(".board-column-"+t).height())}),localStorage.setItem("hidden_column_"+t,1)},Kanboard.BoardColumnView.prototype.showColumn=function(t){$(".board-column-"+t+" .board-column-expanded").show(),$(".board-column-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t+" .board-column-expanded").show(),$(".board-column-header-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t).removeClass("board-column-header-collapsed"),$(".board-column-"+t).removeClass("board-column-task-collapsed"),0==localStorage.getItem("horizontal_scroll")&&$(".board-column-header-"+t).addClass("board-column-compact"),localStorage.removeItem("hidden_column_"+t)},Kanboard.BoardHorizontalScrolling=function(t){this.app=t},Kanboard.BoardHorizontalScrolling.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardHorizontalScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-scrolling",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("c",function(){t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardHorizontalScrolling.prototype.toggle=function(){var t=localStorage.getItem("horizontal_scroll")||1;localStorage.setItem("horizontal_scroll",0==t?1:0),this.render()},Kanboard.BoardHorizontalScrolling.prototype.render=function(){0==localStorage.getItem("horizontal_scroll")?($(".filter-wide").show(),$(".filter-compact").hide(),$("#board-container").addClass("board-container-compact"),$("#board th:not(.board-column-header-collapsed)").addClass("board-column-compact")):($(".filter-wide").hide(),$(".filter-compact").show(),$("#board-container").removeClass("board-container-compact"),$("#board th").removeClass("board-column-compact"))},Kanboard.BoardPolling=function(t){this.app=t},Kanboard.BoardPolling.prototype.execute=function(){if(this.app.hasId("board")){var t=parseInt($("#board").attr("data-check-interval"));t>0&&window.setInterval(this.check.bind(this),1e3*t)}},Kanboard.BoardPolling.prototype.check=function(){if(this.app.isVisible()&&!this.app.get("BoardDragAndDrop").savingInProgress){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$("#board").data("check-url"),statusCode:{200:function(e){t.app.get("BoardDragAndDrop").refresh(e)},304:function(){t.app.hideLoadingIcon()}}})}},Kanboard.BoardTask=function(t){this.app=t},Kanboard.BoardTask.prototype.listen=function(){var t=this;$(document).on("click",".task-board-change-assignee",function(e){e.preventDefault(),e.stopPropagation(),t.app.get("Popover").open($(this).data("url"))}),$(document).on("click",".task-board",function(t){"A"!=t.target.tagName&&"IMG"!=t.target.tagName&&(window.location=$(this).data("task-url"))})},Kanboard.BoardTask.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("n",function(){t.app.get("Popover").open($("#board").data("task-creation-url"))})},Kanboard.BurndownChart=function(t){this.app=t},Kanboard.BurndownChart.prototype.execute=function(){this.app.hasId("analytic-burndown")&&this.show()},Kanboard.BurndownChart.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[[t.data("label-total")]],o=[],n=d3.time.format("%Y-%m-%d"),r=d3.time.format(t.data("date-format")),i=0;i0&&(void 0==a[0][i]&&a[0].push(0),a[0][i]+=e[i][s]),0==s&&o.push(r(n.parse(e[i][s]))));c3.generate({data:{columns:a},axis:{x:{type:"category",categories:o}}})},Kanboard.Calendar=function(t){this.app=t},Kanboard.Calendar.prototype.execute=function(){var t=$("#calendar");1==t.length&&this.show(t)},Kanboard.Calendar.prototype.show=function(t){t.fullCalendar({lang:$("body").data("js-lang"),editable:!0,eventLimit:!0,defaultView:"month",header:{left:"prev,next today",center:"title",right:"month,agendaWeek,agendaDay"},eventDrop:function(e){$.ajax({cache:!1,url:t.data("save-url"),contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({task_id:e.id,date_due:e.start.format()})})},viewRender:function(){var e=t.data("check-url"),a={start:t.fullCalendar("getView").start.format(),end:t.fullCalendar("getView").end.format()};for(var o in a)e+="&"+o+"="+a[o];$.getJSON(e,function(e){t.fullCalendar("removeEvents"),t.fullCalendar("addEventSource",e),t.fullCalendar("rerenderEvents")})}})},Kanboard.Column=function(t){this.app=t},Kanboard.Column.prototype.listen=function(){this.dragAndDrop()},Kanboard.Column.prototype.dragAndDrop=function(){var t=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")}),$(".columns-table tbody").sortable({forcePlaceholderSize:!0,handle:"td:first i",helper:function(t,e){return e.children().each(function(){$(this).width($(this).width())}),e},stop:function(e,a){var o=a.item;o.removeClass("draggable-item-selected"),t.savePosition(o.data("column-id"),o.index()+1)},start:function(t,e){e.item.addClass("draggable-item-selected")}}).disableSelection()},Kanboard.Column.prototype.savePosition=function(t,e){var a=$(".columns-table").data("save-position-url"),o=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:a,contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({column_id:t,position:e}),complete:function(){o.app.hideLoadingIcon()}})},Kanboard.CompareHoursColumnChart=function(t){this.app=t},Kanboard.CompareHoursColumnChart.prototype.execute=function(){this.app.hasId("analytic-compare-hours")&&this.show()},Kanboard.CompareHoursColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=t.data("label-open"),o=t.data("label-closed"),n=[t.data("label-spent")],r=[t.data("label-estimated")],i=[];for(var s in e)n.push(parseFloat(e[s].time_spent)),r.push(parseFloat(e[s].time_estimated)),i.push("open"==s?a:o);c3.generate({data:{columns:[n,r],type:"bar"},bar:{width:{ratio:.2}},axis:{x:{type:"category",categories:i}},legend:{show:!0}})},Kanboard.CumulativeFlowDiagram=function(t){this.app=t},Kanboard.CumulativeFlowDiagram.prototype.execute=function(){this.app.hasId("analytic-cfd")&&this.show()},Kanboard.CumulativeFlowDiagram.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[],o=[],n=[],r=d3.time.format("%Y-%m-%d"),i=d3.time.format(t.data("date-format")),s=0;s0&&o.push(e[s][d])):(a[d].push(e[s][d]),0==d&&n.push(i(r.parse(e[s][d]))));c3.generate({data:{columns:a,type:"area-spline",groups:[o]},axis:{x:{type:"category",categories:n}}})},Kanboard.Dropdown=function(t){this.app=t},Kanboard.Dropdown.prototype.listen=function(){var t=this;$(document).on("click",function(){t.close()}),$(document).on("click",".dropdown-menu",function(e){e.preventDefault(),e.stopImmediatePropagation(),t.close();var a=$(this).next("ul"),o=$(this).offset();$("body").append(jQuery("
",{id:"dropdown"})),a.clone().appendTo("#dropdown");var n=$("#dropdown ul");n.addClass("dropdown-submenu-open");var r=n.outerHeight(),i=n.outerWidth();o.top+r-$(window).scrollTop()<$(window).height()||$(window).scrollTop()+o.top$(window).width()?n.css("left",o.left-i+$(this).outerWidth()):n.css("left",o.left)}),$(document).on("click",".dropdown-submenu-open li",function(t){$(t.target).is("li")&&$(this).find("a:visible")[0].click()})},Kanboard.Dropdown.prototype.close=function(){$("#dropdown").remove()},Kanboard.Dropdown.prototype.onPopoverOpened=function(){this.close()},Kanboard.FileUpload=function(t){this.app=t,this.files=[],this.currentFile=0},Kanboard.FileUpload.prototype.onPopoverOpened=function(){var t=document.getElementById("file-dropzone"),e=this;t&&(t.ondragover=t.ondragenter=function(t){t.stopPropagation(),t.preventDefault()},t.ondrop=function(t){t.stopPropagation(),t.preventDefault(),e.files=t.dataTransfer.files,e.show(),$("#file-error-max-size").hide()},$(document).on("click","#file-browser",function(t){t.preventDefault(),$("#file-form-element").get(0).click()}),$(document).on("click","#file-upload-button",function(t){t.preventDefault(),e.currentFile=0,e.checkFiles()}),$("#file-form-element").change(function(){e.files=document.getElementById("file-form-element").files,e.show(),$("#file-error-max-size").hide()}))},Kanboard.FileUpload.prototype.show=function(){if($("#file-list").remove(),this.files.length>0){$("#file-upload-button").prop("disabled",!1),$("#file-dropzone-inner").hide();for(var t=jQuery("
diff --git a/doc/plugin-hooks.markdown b/doc/plugin-hooks.markdown index 1f90bdbc3..787c62df4 100644 --- a/doc/plugin-hooks.markdown +++ b/doc/plugin-hooks.markdown @@ -155,6 +155,7 @@ List of template hooks: | `template:board:public:task:after-title` | Task in public board: after title | | `template:board:task:footer` | Task in board: footer | | `template:board:task:icons` | Task in board: tooltip icon | +| `template:board:column:dropdown` | Dropdown menu in board columns | | `template:config:sidebar` | Sidebar on settings page | | `template:config:application ` | Application settings form | | `template:config:email` | Email settings page | @@ -162,7 +163,8 @@ List of template hooks: | `template:dashboard:sidebar` | Sidebar on dashboard page | | `template:export:sidebar` | Sidebar on export pages | | `template:import:sidebar` | Sidebar on import pages | -| `template:header:dropdown` | Dropdown on header | +| `template:header:dropdown` | Page header dropdown menu (user avatar icon) | +| `template:header:creation-dropdown` | Page header dropdown menu (plus icon) | | `template:layout:head` | Page layout `` tag | | `template:layout:top` | Page layout top header | | `template:layout:bottom` | Page layout footer | From b6119e7dee84869a619dedccd9c80df4422a4f5b Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:05:15 -0400 Subject: [PATCH 104/139] Added internal task links to activity stream --- ChangeLog | 1 + app/Action/TaskAssignCategoryLink.php | 13 +- app/Action/TaskAssignColorLink.php | 10 +- app/Core/Base.php | 1 + app/EventBuilder/TaskLinkEventBuilder.php | 89 +++++++++ app/Helper/HookHelper.php | 2 +- app/Job/TaskLinkEventJob.php | 45 +++++ app/Model/NotificationModel.php | 39 ++-- app/Model/TaskLinkModel.php | 175 ++++++++++-------- app/ServiceProvider/JobProvider.php | 5 + app/Subscriber/NotificationSubscriber.php | 3 + .../task_internal_link_create_update.php | 16 ++ .../event/task_internal_link_delete.php | 16 ++ .../notification/task_file_create.php | 2 +- .../task_internal_link_create_update.php | 11 ++ .../task_internal_link_delete.php | 11 ++ .../Action/TaskAssignCategoryLinkTest.php | 51 ++--- .../units/Action/TaskAssignColorLinkTest.php | 45 +++-- .../EventBuilder/TaskLinkEventBuilderTest.php | 70 +++++++ tests/units/Job/TaskLinkEventJobTest.php | 65 +++++++ tests/units/Model/NotificationModelTest.php | 39 ++-- tests/units/Model/TaskLinkModelTest.php | 28 +++ .../Notification/MailNotificationTest.php | 117 ++++++++++++ tests/units/Notification/MailTest.php | 117 ------------ ...okTest.php => WebhookNotificationTest.php} | 14 +- 25 files changed, 684 insertions(+), 301 deletions(-) create mode 100644 app/EventBuilder/TaskLinkEventBuilder.php create mode 100644 app/Job/TaskLinkEventJob.php create mode 100644 app/Template/event/task_internal_link_create_update.php create mode 100644 app/Template/event/task_internal_link_delete.php create mode 100644 app/Template/notification/task_internal_link_create_update.php create mode 100644 app/Template/notification/task_internal_link_delete.php create mode 100644 tests/units/EventBuilder/TaskLinkEventBuilderTest.php create mode 100644 tests/units/Job/TaskLinkEventJobTest.php create mode 100644 tests/units/Notification/MailNotificationTest.php delete mode 100644 tests/units/Notification/MailTest.php rename tests/units/Notification/{WebhookTest.php => WebhookNotificationTest.php} (53%) diff --git a/ChangeLog b/ChangeLog index a1e394360..ee57c86c5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,7 @@ Version 1.0.32 (unreleased) New features: * New automated action to close tasks without activity in a specific column +* Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority * Added the possibility to hide tasks in dashboard for a specific column diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php index 6937edd1b..d4a4c0ec8 100644 --- a/app/Action/TaskAssignCategoryLink.php +++ b/app/Action/TaskAssignCategoryLink.php @@ -60,8 +60,10 @@ class TaskAssignCategoryLink extends Base public function getEventRequiredParameters() { return array( - 'task_id', - 'link_id', + 'task_link' => array( + 'task_id', + 'link_id', + ) ); } @@ -75,7 +77,7 @@ class TaskAssignCategoryLink extends Base public function doAction(array $data) { $values = array( - 'id' => $data['task_id'], + 'id' => $data['task_link']['task_id'], 'category_id' => $this->getParam('category_id'), ); @@ -91,9 +93,8 @@ class TaskAssignCategoryLink extends Base */ public function hasRequiredCondition(array $data) { - if ($data['link_id'] == $this->getParam('link_id')) { - $task = $this->taskFinderModel->getById($data['task_id']); - return empty($task['category_id']); + if ($data['task_link']['link_id'] == $this->getParam('link_id')) { + return empty($data['task']['category_id']); } return false; diff --git a/app/Action/TaskAssignColorLink.php b/app/Action/TaskAssignColorLink.php index 9ab5458b2..9759f6224 100644 --- a/app/Action/TaskAssignColorLink.php +++ b/app/Action/TaskAssignColorLink.php @@ -59,8 +59,10 @@ class TaskAssignColorLink extends Base public function getEventRequiredParameters() { return array( - 'task_id', - 'link_id', + 'task_link' => array( + 'task_id', + 'link_id', + ) ); } @@ -74,7 +76,7 @@ class TaskAssignColorLink extends Base public function doAction(array $data) { $values = array( - 'id' => $data['task_id'], + 'id' => $data['task_link']['task_id'], 'color_id' => $this->getParam('color_id'), ); @@ -90,6 +92,6 @@ class TaskAssignColorLink extends Base */ public function hasRequiredCondition(array $data) { - return $data['link_id'] == $this->getParam('link_id'); + return $data['task_link']['link_id'] == $this->getParam('link_id'); } } diff --git a/app/Core/Base.php b/app/Core/Base.php index 098bd8804..20a2d391b 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -154,6 +154,7 @@ use Pimple\Container; * @property \Kanboard\Job\SubtaskEventJob $subtaskEventJob * @property \Kanboard\Job\TaskEventJob $taskEventJob * @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob + * @property \Kanboard\Job\TaskLinkEventJob $taskLinkEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob * @property \Psr\Log\LoggerInterface $logger diff --git a/app/EventBuilder/TaskLinkEventBuilder.php b/app/EventBuilder/TaskLinkEventBuilder.php new file mode 100644 index 000000000..8be5299f7 --- /dev/null +++ b/app/EventBuilder/TaskLinkEventBuilder.php @@ -0,0 +1,89 @@ +taskLinkId = $taskLinkId; + return $this; + } + + /** + * Build event data + * + * @access public + * @return TaskLinkEvent|null + */ + public function build() + { + $taskLink = $this->taskLinkModel->getById($this->taskLinkId); + + if (empty($taskLink)) { + $this->logger->debug(__METHOD__.': TaskLink not found'); + return null; + } + + return new TaskLinkEvent(array( + 'task_link' => $taskLink, + 'task' => $this->taskFinderModel->getDetails($taskLink['task_id']), + )); + } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + if ($eventName === TaskLinkModel::EVENT_CREATE_UPDATE) { + return e('%s set a new internal link for the task #%d', $author, $eventData['task']['id']); + } elseif ($eventName === TaskLinkModel::EVENT_DELETE) { + return e('%s removed an internal link for the task #%d', $author, $eventData['task']['id']); + } + + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + if ($eventName === TaskLinkModel::EVENT_CREATE_UPDATE) { + return e('A new internal link for the task #%d have been defined', $eventData['task']['id']); + } elseif ($eventName === TaskLinkModel::EVENT_DELETE) { + return e('Internal link removed for the task #%d', $eventData['task']['id']); + } + + return ''; + } +} diff --git a/app/Helper/HookHelper.php b/app/Helper/HookHelper.php index 2d13ebcc5..cb4dc1efe 100644 --- a/app/Helper/HookHelper.php +++ b/app/Helper/HookHelper.php @@ -56,7 +56,7 @@ class HookHelper extends Base * @access public * @param string $hook * @param string $template - * @return \Kanboard\Helper\Hook + * @return $this */ public function attach($hook, $template) { diff --git a/app/Job/TaskLinkEventJob.php b/app/Job/TaskLinkEventJob.php new file mode 100644 index 000000000..669608ada --- /dev/null +++ b/app/Job/TaskLinkEventJob.php @@ -0,0 +1,45 @@ +jobParams = array($taskLinkId, $eventName); + return $this; + } + + /** + * Execute job + * + * @param int $taskLinkId + * @param string $eventName + * @return $this + */ + public function execute($taskLinkId, $eventName) + { + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId($taskLinkId) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index 925d646e4..39c1f5813 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -3,6 +3,7 @@ namespace Kanboard\Model; use Kanboard\Core\Base; +use Kanboard\EventBuilder\TaskLinkEventBuilder; /** * Notification @@ -85,7 +86,9 @@ class NotificationModel extends Base case CommentModel::EVENT_USER_MENTION: return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']); default: - return e('Notification'); + return TaskLinkEventBuilder::getInstance($this->container) + ->buildTitleWithAuthor($event_author, $event_name, $event_data) ?: + e('Notification'); } } @@ -138,7 +141,9 @@ class NotificationModel extends Base case CommentModel::EVENT_USER_MENTION: return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']); default: - return e('Notification'); + return TaskLinkEventBuilder::getInstance($this->container) + ->buildTitleWithoutAuthor($event_name, $event_data) ?: + e('Notification'); } } @@ -152,32 +157,10 @@ class NotificationModel extends Base */ public function getTaskIdFromEvent($event_name, array $event_data) { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - return $event_data['file']['task_id']; - case CommentModel::EVENT_CREATE: - case CommentModel::EVENT_UPDATE: - case CommentModel::EVENT_DELETE: - return $event_data['comment']['task_id']; - case SubtaskModel::EVENT_CREATE: - case SubtaskModel::EVENT_UPDATE: - case SubtaskModel::EVENT_DELETE: - return $event_data['subtask']['task_id']; - case TaskModel::EVENT_CREATE: - case TaskModel::EVENT_UPDATE: - case TaskModel::EVENT_CLOSE: - case TaskModel::EVENT_OPEN: - case TaskModel::EVENT_MOVE_COLUMN: - case TaskModel::EVENT_MOVE_POSITION: - case TaskModel::EVENT_MOVE_SWIMLANE: - case TaskModel::EVENT_ASSIGNEE_CHANGE: - case CommentModel::EVENT_USER_MENTION: - case TaskModel::EVENT_USER_MENTION: - return $event_data['task']['id']; - case TaskModel::EVENT_OVERDUE: - return $event_data['tasks'][0]['id']; - default: - return 0; + if ($event_name === TaskModel::EVENT_OVERDUE) { + return $event_data['tasks'][0]['id']; } + + return isset($event_data['task']['id']) ? $event_data['task']['id'] : 0; } } diff --git a/app/Model/TaskLinkModel.php b/app/Model/TaskLinkModel.php index 09978eae8..e8d3c5dfa 100644 --- a/app/Model/TaskLinkModel.php +++ b/app/Model/TaskLinkModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskLinkEvent; /** * TaskLink model @@ -26,7 +25,8 @@ class TaskLinkModel extends Base * * @var string */ - const EVENT_CREATE_UPDATE = 'tasklink.create_update'; + const EVENT_CREATE_UPDATE = 'task_internal_link.create_update'; + const EVENT_DELETE = 'task_internal_link.delete'; /** * Get projectId from $task_link_id @@ -53,7 +53,19 @@ class TaskLinkModel extends Base */ public function getById($task_link_id) { - return $this->db->table(self::TABLE)->eq('id', $task_link_id)->findOne(); + return $this->db + ->table(self::TABLE) + ->columns( + self::TABLE.'.id', + self::TABLE.'.opposite_task_id', + self::TABLE.'.task_id', + self::TABLE.'.link_id', + LinkModel::TABLE.'.label', + LinkModel::TABLE.'.opposite_id AS opposite_link_id' + ) + ->eq(self::TABLE.'.id', $task_link_id) + ->join(LinkModel::TABLE, 'id', 'link_id') + ->findOne(); } /** @@ -139,20 +151,6 @@ class TaskLinkModel extends Base return $result; } - /** - * Publish events - * - * @access private - * @param array $events - */ - private function fireEvents(array $events) - { - foreach ($events as $event) { - $event['project_id'] = $this->taskFinderModel->getProjectId($event['task_id']); - $this->container['dispatcher']->dispatch(self::EVENT_CREATE_UPDATE, new TaskLinkEvent($event)); - } - } - /** * Create a new link * @@ -160,42 +158,25 @@ class TaskLinkModel extends Base * @param integer $task_id Task id * @param integer $opposite_task_id Opposite task id * @param integer $link_id Link id - * @return integer Task link id + * @return integer|boolean */ public function create($task_id, $opposite_task_id, $link_id) { - $events = array(); $this->db->startTransaction(); - // Get opposite link $opposite_link_id = $this->linkModel->getOppositeLinkId($link_id); + $task_link_id1 = $this->createTaskLink($task_id, $opposite_task_id, $link_id); + $task_link_id2 = $this->createTaskLink($opposite_task_id, $task_id, $opposite_link_id); - $values = array( - 'task_id' => $task_id, - 'opposite_task_id' => $opposite_task_id, - 'link_id' => $link_id, - ); - - // Create the original task link - $this->db->table(self::TABLE)->insert($values); - $task_link_id = $this->db->getLastId(); - $events[] = $values; - - // Create the opposite task link - $values = array( - 'task_id' => $opposite_task_id, - 'opposite_task_id' => $task_id, - 'link_id' => $opposite_link_id, - ); - - $this->db->table(self::TABLE)->insert($values); - $events[] = $values; + if ($task_link_id1 === false || $task_link_id2 === false) { + $this->db->cancelTransaction(); + return false; + } $this->db->closeTransaction(); + $this->fireEvents(array($task_link_id1, $task_link_id2), self::EVENT_CREATE_UPDATE); - $this->fireEvents($events); - - return (int) $task_link_id; + return $task_link_id1; } /** @@ -210,46 +191,24 @@ class TaskLinkModel extends Base */ public function update($task_link_id, $task_id, $opposite_task_id, $link_id) { - $events = array(); $this->db->startTransaction(); - // Get original task link $task_link = $this->getById($task_link_id); - - // Find opposite task link $opposite_task_link = $this->getOppositeTaskLink($task_link); - - // Get opposite link $opposite_link_id = $this->linkModel->getOppositeLinkId($link_id); - // Update the original task link - $values = array( - 'task_id' => $task_id, - 'opposite_task_id' => $opposite_task_id, - 'link_id' => $link_id, - ); + $result1 = $this->updateTaskLink($task_link_id, $task_id, $opposite_task_id, $link_id); + $result2 = $this->updateTaskLink($opposite_task_link['id'], $opposite_task_id, $task_id, $opposite_link_id); - $rs1 = $this->db->table(self::TABLE)->eq('id', $task_link_id)->update($values); - $events[] = $values; - - // Update the opposite link - $values = array( - 'task_id' => $opposite_task_id, - 'opposite_task_id' => $task_id, - 'link_id' => $opposite_link_id, - ); - - $rs2 = $this->db->table(self::TABLE)->eq('id', $opposite_task_link['id'])->update($values); - $events[] = $values; - - $this->db->closeTransaction(); - - if ($rs1 && $rs2) { - $this->fireEvents($events); - return true; + if ($result1 === false || $result2 === false) { + $this->db->cancelTransaction(); + return false; } - return false; + $this->db->closeTransaction(); + $this->fireEvents(array($task_link_id, $opposite_task_link['id']), self::EVENT_CREATE_UPDATE); + + return true; } /** @@ -261,21 +220,83 @@ class TaskLinkModel extends Base */ public function remove($task_link_id) { + $this->taskLinkEventJob->execute($task_link_id, self::EVENT_DELETE); + $this->db->startTransaction(); $link = $this->getById($task_link_id); $link_id = $this->linkModel->getOppositeLinkId($link['link_id']); - $this->db->table(self::TABLE)->eq('id', $task_link_id)->remove(); + $result1 = $this->db + ->table(self::TABLE) + ->eq('id', $task_link_id) + ->remove(); - $this->db + $result2 = $this->db ->table(self::TABLE) ->eq('opposite_task_id', $link['task_id']) ->eq('task_id', $link['opposite_task_id']) - ->eq('link_id', $link_id)->remove(); + ->eq('link_id', $link_id) + ->remove(); + + if ($result1 === false || $result2 === false) { + $this->db->cancelTransaction(); + return false; + } $this->db->closeTransaction(); return true; } + + /** + * Publish events + * + * @access protected + * @param integer[] $task_link_ids + * @param string $eventName + */ + protected function fireEvents(array $task_link_ids, $eventName) + { + foreach ($task_link_ids as $task_link_id) { + $this->queueManager->push($this->taskLinkEventJob->withParams($task_link_id, $eventName)); + } + } + + /** + * Create task link + * + * @access protected + * @param integer $task_id + * @param integer $opposite_task_id + * @param integer $link_id + * @return integer|boolean + */ + protected function createTaskLink($task_id, $opposite_task_id, $link_id) + { + return $this->db->table(self::TABLE)->persist(array( + 'task_id' => $task_id, + 'opposite_task_id' => $opposite_task_id, + 'link_id' => $link_id, + )); + } + + /** + * Update task link + * + * @access protected + * @param integer $task_link_id + * @param integer $task_id + * @param integer $opposite_task_id + * @param integer $link_id + * @return boolean + */ + protected function updateTaskLink($task_link_id, $task_id, $opposite_task_id, $link_id) + { + return $this->db->table(self::TABLE)->eq('id', $task_link_id)->update(array( + 'task_id' => $task_id, + 'opposite_task_id' => $opposite_task_id, + 'link_id' => $link_id, + )); + } } diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php index c7f323f12..5b42794b4 100644 --- a/app/ServiceProvider/JobProvider.php +++ b/app/ServiceProvider/JobProvider.php @@ -8,6 +8,7 @@ use Kanboard\Job\ProjectFileEventJob; use Kanboard\Job\SubtaskEventJob; use Kanboard\Job\TaskEventJob; use Kanboard\Job\TaskFileEventJob; +use Kanboard\Job\TaskLinkEventJob; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -44,6 +45,10 @@ class JobProvider implements ServiceProviderInterface return new TaskFileEventJob($c); }); + $container['taskLinkEventJob'] = $container->factory(function ($c) { + return new TaskLinkEventJob($c); + }); + $container['projectFileEventJob'] = $container->factory(function ($c) { return new ProjectFileEventJob($c); }); diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 7de24e49e..7cc68b26f 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -3,6 +3,7 @@ namespace Kanboard\Subscriber; use Kanboard\Event\GenericEvent; +use Kanboard\Model\TaskLinkModel; use Kanboard\Model\TaskModel; use Kanboard\Model\CommentModel; use Kanboard\Model\SubtaskModel; @@ -31,6 +32,8 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn CommentModel::EVENT_DELETE => 'handleEvent', CommentModel::EVENT_USER_MENTION => 'handleEvent', TaskFileModel::EVENT_CREATE => 'handleEvent', + TaskLinkModel::EVENT_CREATE_UPDATE => 'handleEvent', + TaskLinkModel::EVENT_DELETE => 'handleEvent', ); } diff --git a/app/Template/event/task_internal_link_create_update.php b/app/Template/event/task_internal_link_create_update.php new file mode 100644 index 000000000..de257977c --- /dev/null +++ b/app/Template/event/task_internal_link_create_update.php @@ -0,0 +1,16 @@ +

+ text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

+
+

+ url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])), + $this->text->e($task_link['label']) + ) ?> +

+
diff --git a/app/Template/event/task_internal_link_delete.php b/app/Template/event/task_internal_link_delete.php new file mode 100644 index 000000000..e537bf815 --- /dev/null +++ b/app/Template/event/task_internal_link_delete.php @@ -0,0 +1,16 @@ +

+ text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

+
+

+ text->e($task_link['label']), + $this->url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])) + ) ?> +

+
diff --git a/app/Template/notification/task_file_create.php b/app/Template/notification/task_file_create.php index feab8dd2c..c19f72794 100644 --- a/app/Template/notification/task_file_create.php +++ b/app/Template/notification/task_file_create.php @@ -2,4 +2,4 @@

-render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> \ No newline at end of file +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/Template/notification/task_internal_link_create_update.php b/app/Template/notification/task_internal_link_create_update.php new file mode 100644 index 000000000..73cad84dc --- /dev/null +++ b/app/Template/notification/task_internal_link_create_update.php @@ -0,0 +1,11 @@ +

text->e($task['title']) ?> (#)

+ +

+ url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])), + $this->text->e($task_link['label']) + ) ?> +

+ +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/Template/notification/task_internal_link_delete.php b/app/Template/notification/task_internal_link_delete.php new file mode 100644 index 000000000..bb54e0a7d --- /dev/null +++ b/app/Template/notification/task_internal_link_delete.php @@ -0,0 +1,11 @@ +

text->e($task['title']) ?> (#)

+ +

+ text->e($task_link['label']), + $this->url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])) + ) ?> +

+ +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php index d7e68f727..b9d7e9d98 100644 --- a/tests/units/Action/TaskAssignCategoryLinkTest.php +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -2,12 +2,12 @@ require_once __DIR__.'/../Base.php'; +use Kanboard\EventBuilder\TaskLinkEventBuilder; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; use Kanboard\Model\TaskLinkModel; use Kanboard\Model\CategoryModel; -use Kanboard\Event\TaskLinkEvent; use Kanboard\Action\TaskAssignCategoryLink; class TaskAssignCategoryLinkTest extends Base @@ -18,6 +18,7 @@ class TaskAssignCategoryLinkTest extends Base $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); @@ -27,13 +28,12 @@ class TaskAssignCategoryLinkTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -44,51 +44,58 @@ class TaskAssignCategoryLinkTest extends Base public function testWhenLinkDontMatch() { $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); $action->setParam('category_id', 1); - $action->setParam('link_id', 1); + $action->setParam('link_id', 2); $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(0, $task['category_id']); } public function testThatExistingCategoryWillNotChange() { $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); - $action->setParam('category_id', 2); + $action->setParam('category_id', 1); $action->setParam('link_id', 2); $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); - $this->assertEquals(2, $categoryModel->create(array('name' => 'C2', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['category_id']); } } diff --git a/tests/units/Action/TaskAssignColorLinkTest.php b/tests/units/Action/TaskAssignColorLinkTest.php index 07d0969be..27364bc9e 100644 --- a/tests/units/Action/TaskAssignColorLinkTest.php +++ b/tests/units/Action/TaskAssignColorLinkTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\EventBuilder\TaskLinkEventBuilder; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -13,42 +13,55 @@ class TaskAssignColorLinkTest extends Base { public function testChangeColor() { - $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); $taskFinderModel = new TaskFinderModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 1)); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignColorLink($this->container); $action->setProjectId(1); + $action->setParam('link_id', 2); $action->setParam('color_id', 'red'); - $action->setParam('link_id', 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); + + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); $task = $taskFinderModel->getById(1); - $this->assertNotEmpty($task); $this->assertEquals('red', $task['color_id']); } public function testWithWrongLink() { - $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 2)); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignColorLink($this->container); $action->setProjectId(1); + $action->setParam('link_id', 2); $action->setParam('color_id', 'red'); - $action->setParam('link_id', 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals('yellow', $task['color_id']); } } diff --git a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php new file mode 100644 index 000000000..7364d6512 --- /dev/null +++ b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php @@ -0,0 +1,70 @@ +container); + $taskLinkEventBuilder->withTaskLinkId(42); + $this->assertNull($taskLinkEventBuilder->build()); + } + + public function testBuild() + { + $taskLinkModel = new TaskLinkModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $event = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskLinkEvent', $event); + $this->assertNotEmpty($event['task_link']); + $this->assertNotEmpty($event['task']); + } + + public function testBuildTitle() + { + $taskLinkModel = new TaskLinkModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); + $this->assertEquals('Foobar set a new internal link for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_DELETE, $eventData->getAll()); + $this->assertEquals('Foobar removed an internal link for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', 'not found', $eventData->getAll()); + $this->assertSame('', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor(TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); + $this->assertEquals('A new internal link for the task #1 have been defined', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor(TaskLinkModel::EVENT_DELETE, $eventData->getAll()); + $this->assertEquals('Internal link removed for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor('not found', $eventData->getAll()); + $this->assertSame('', $title); + } +} diff --git a/tests/units/Job/TaskLinkEventJobTest.php b/tests/units/Job/TaskLinkEventJobTest.php new file mode 100644 index 000000000..1949316a1 --- /dev/null +++ b/tests/units/Job/TaskLinkEventJobTest.php @@ -0,0 +1,65 @@ +container); + $taskLinkEventJob->withParams(123, 'foobar'); + + $this->assertSame(array(123, 'foobar'), $taskLinkEventJob->getJobParams()); + } + + public function testWithMissingLink() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_CREATE_UPDATE, function() {}); + + $taskLinkEventJob = new TaskLinkEventJob($this->container); + $taskLinkEventJob->execute(42, TaskLinkModel::EVENT_CREATE_UPDATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerCreationEvents() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_CREATE_UPDATE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskLinkModel::EVENT_CREATE_UPDATE.'.closure', $called); + } + + public function testTriggerDeleteEvents() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_DELETE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + $this->assertTrue($taskLinkModel->remove(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskLinkModel::EVENT_DELETE.'.closure', $called); + } +} diff --git a/tests/units/Model/NotificationModelTest.php b/tests/units/Model/NotificationModelTest.php index 889f3349e..0bd9db6e8 100644 --- a/tests/units/Model/NotificationModelTest.php +++ b/tests/units/Model/NotificationModelTest.php @@ -7,6 +7,7 @@ use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\CommentModel; use Kanboard\Model\TaskFileModel; +use Kanboard\Model\TaskLinkModel; use Kanboard\Model\TaskModel; use Kanboard\Model\ProjectModel; use Kanboard\Model\NotificationModel; @@ -23,47 +24,38 @@ class NotificationModelTest extends Base $subtaskModel = new SubtaskModel($this->container); $commentModel = new CommentModel($this->container); $taskFileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'test', 'task_id' => 1))); $this->assertEquals(1, $commentModel->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); $this->assertEquals(1, $taskFileModel->create(1, 'test', 'blah', 123)); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); $task = $taskFinderModel->getDetails(1); $subtask = $subtaskModel->getById(1, true); $comment = $commentModel->getById(1); $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); - $this->assertNotEmpty($task); - $this->assertNotEmpty($subtask); - $this->assertNotEmpty($comment); - $this->assertNotEmpty($file); - - foreach (NotificationSubscriber::getSubscribedEvents() as $event_name => $values) { - $title = $notificationModel->getTitleWithoutAuthor($event_name, array( + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $eventData = array( 'task' => $task, 'comment' => $comment, 'subtask' => $subtask, 'file' => $file, + 'task_link' => $tasklink, 'changes' => array() - )); + ); - $this->assertNotEmpty($title); - - $title = $notificationModel->getTitleWithAuthor('foobar', $event_name, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array() - )); - - $this->assertNotEmpty($title); + $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor($eventName, $eventData)); + $this->assertNotEmpty($notificationModel->getTitleWithAuthor('Foobar', $eventName, $eventData)); } $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor(TaskModel::EVENT_OVERDUE, array('tasks' => array(array('id' => 1))))); - $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor('unkown', array())); + $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor('unknown', array())); } public function testGetTaskIdFromEvent() @@ -75,6 +67,7 @@ class NotificationModelTest extends Base $subtaskModel = new SubtaskModel($this->container); $commentModel = new CommentModel($this->container); $taskFileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); @@ -86,18 +79,20 @@ class NotificationModelTest extends Base $subtask = $subtaskModel->getById(1, true); $comment = $commentModel->getById(1); $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); $this->assertNotEmpty($task); $this->assertNotEmpty($subtask); $this->assertNotEmpty($comment); $this->assertNotEmpty($file); - foreach (NotificationSubscriber::getSubscribedEvents() as $event_name => $values) { - $task_id = $notificationModel->getTaskIdFromEvent($event_name, array( + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $task_id = $notificationModel->getTaskIdFromEvent($eventName, array( 'task' => $task, 'comment' => $comment, 'subtask' => $subtask, 'file' => $file, + 'task_link' => $tasklink, 'changes' => array() )); diff --git a/tests/units/Model/TaskLinkModelTest.php b/tests/units/Model/TaskLinkModelTest.php index 78590891e..01a7888b1 100644 --- a/tests/units/Model/TaskLinkModelTest.php +++ b/tests/units/Model/TaskLinkModelTest.php @@ -9,6 +9,34 @@ use Kanboard\Model\ProjectModel; class TaskLinkModelTest extends Base { + public function testGeyById() + { + $taskLinkModel = new TaskLinkModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'A'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'B'))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 6)); + + $taskLink = $taskLinkModel->getById(1); + $this->assertEquals(1, $taskLink['id']); + $this->assertEquals(1, $taskLink['task_id']); + $this->assertEquals(2, $taskLink['opposite_task_id']); + $this->assertEquals(6, $taskLink['link_id']); + $this->assertEquals(7, $taskLink['opposite_link_id']); + $this->assertEquals('is a child of', $taskLink['label']); + + $taskLink = $taskLinkModel->getById(2); + $this->assertEquals(2, $taskLink['id']); + $this->assertEquals(2, $taskLink['task_id']); + $this->assertEquals(1, $taskLink['opposite_task_id']); + $this->assertEquals(7, $taskLink['link_id']); + $this->assertEquals(6, $taskLink['opposite_link_id']); + $this->assertEquals('is a parent of', $taskLink['label']); + } + // Check postgres issue: "Cardinality violation: 7 ERROR: more than one row returned by a subquery used as an expression" public function testGetTaskWithMultipleMilestoneLink() { diff --git a/tests/units/Notification/MailNotificationTest.php b/tests/units/Notification/MailNotificationTest.php new file mode 100644 index 000000000..6579d9bcd --- /dev/null +++ b/tests/units/Notification/MailNotificationTest.php @@ -0,0 +1,117 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $commentModel = new CommentModel($this->container); + $fileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'test', 'task_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); + $this->assertEquals(1, $fileModel->create(1, 'test', 'blah', 123)); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $task = $taskFinderModel->getDetails(1); + $subtask = $subtaskModel->getById(1, true); + $comment = $commentModel->getById(1); + $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); + + $this->assertNotEmpty($task); + $this->assertNotEmpty($subtask); + $this->assertNotEmpty($comment); + $this->assertNotEmpty($file); + + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $eventData = array( + 'task' => $task, + 'comment' => $comment, + 'subtask' => $subtask, + 'file' => $file, + 'task_link' => $tasklink, + 'changes' => array() + ); + $this->assertNotEmpty($mailNotification->getMailContent($eventName, $eventData)); + $this->assertNotEmpty($mailNotification->getMailSubject($eventName, $eventData)); + } + } + + public function testSendWithEmailAddress() + { + $mailNotification = new MailNotification($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $userModel = new UserModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($userModel->update(array('id' => 1, 'email' => 'test@localhost'))); + + $this->container['emailClient'] = $this + ->getMockBuilder('\Kanboard\Core\Mail\Client') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('send')) + ->getMock(); + + $this->container['emailClient'] + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('test@localhost'), + $this->equalTo('admin'), + $this->equalTo('[test][New task] test (#1)'), + $this->stringContains('test') + ); + + $mailNotification->notifyUser($userModel->getById(1), TaskModel::EVENT_CREATE, array('task' => $taskFinderModel->getDetails(1))); + } + + public function testSendWithoutEmailAddress() + { + $mailNotification = new MailNotification($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $userModel = new UserModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $this->container['emailClient'] = $this + ->getMockBuilder('\Kanboard\Core\Mail\Client') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('send')) + ->getMock(); + + $this->container['emailClient'] + ->expects($this->never()) + ->method('send'); + + $mailNotification->notifyUser($userModel->getById(1), TaskModel::EVENT_CREATE, array('task' => $taskFinderModel->getDetails(1))); + } +} diff --git a/tests/units/Notification/MailTest.php b/tests/units/Notification/MailTest.php deleted file mode 100644 index 9f077ac81..000000000 --- a/tests/units/Notification/MailTest.php +++ /dev/null @@ -1,117 +0,0 @@ -container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $c = new CommentModel($this->container); - $f = new TaskFileModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'test', 'task_id' => 1))); - $this->assertEquals(1, $c->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); - $this->assertEquals(1, $f->create(1, 'test', 'blah', 123)); - - $task = $tf->getDetails(1); - $subtask = $s->getById(1, true); - $comment = $c->getById(1); - $file = $c->getById(1); - - $this->assertNotEmpty($task); - $this->assertNotEmpty($subtask); - $this->assertNotEmpty($comment); - $this->assertNotEmpty($file); - - foreach (NotificationSubscriber::getSubscribedEvents() as $event => $values) { - $this->assertNotEmpty($en->getMailContent($event, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array()) - )); - - $this->assertNotEmpty($en->getMailSubject($event, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array()) - )); - } - } - - public function testSendWithEmailAddress() - { - $en = new MailNotification($this->container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $u = new UserModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - $this->assertTrue($u->update(array('id' => 1, 'email' => 'test@localhost'))); - - $this->container['emailClient'] = $this - ->getMockBuilder('\Kanboard\Core\Mail\Client') - ->setConstructorArgs(array($this->container)) - ->setMethods(array('send')) - ->getMock(); - - $this->container['emailClient'] - ->expects($this->once()) - ->method('send') - ->with( - $this->equalTo('test@localhost'), - $this->equalTo('admin'), - $this->equalTo('[test][New task] test (#1)'), - $this->stringContains('test') - ); - - $en->notifyUser($u->getById(1), TaskModel::EVENT_CREATE, array('task' => $tf->getDetails(1))); - } - - public function testSendWithoutEmailAddress() - { - $en = new MailNotification($this->container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $u = new UserModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - - $this->container['emailClient'] = $this - ->getMockBuilder('\Kanboard\Core\Mail\Client') - ->setConstructorArgs(array($this->container)) - ->setMethods(array('send')) - ->getMock(); - - $this->container['emailClient'] - ->expects($this->never()) - ->method('send'); - - $en->notifyUser($u->getById(1), TaskModel::EVENT_CREATE, array('task' => $tf->getDetails(1))); - } -} diff --git a/tests/units/Notification/WebhookTest.php b/tests/units/Notification/WebhookNotificationTest.php similarity index 53% rename from tests/units/Notification/WebhookTest.php rename to tests/units/Notification/WebhookNotificationTest.php index 5a9eb1c71..6fbc349cb 100644 --- a/tests/units/Notification/WebhookTest.php +++ b/tests/units/Notification/WebhookNotificationTest.php @@ -7,23 +7,23 @@ use Kanboard\Model\TaskCreationModel; use Kanboard\Model\ProjectModel; use Kanboard\Subscriber\NotificationSubscriber; -class WebhookTest extends Base +class WebhookNotificationTest extends Base { public function testTaskCreation() { - $c = new ConfigModel($this->container); - $p = new ProjectModel($this->container); - $tc = new TaskCreationModel($this->container); + $configModel = new ConfigModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); $this->container['dispatcher']->addSubscriber(new NotificationSubscriber($this->container)); - $c->save(array('webhook_url' => 'http://localhost/?task-creation')); + $configModel->save(array('webhook_url' => 'http://localhost/?task-creation')); $this->container['httpClient'] ->expects($this->once()) ->method('postJson') ->with($this->stringContains('http://localhost/?task-creation&token='), $this->anything()); - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); } } From a823cc1d08535539f850711c0b9edb5b648f1960 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:50:59 -0400 Subject: [PATCH 105/139] NotificationModel refactoring --- app/EventBuilder/BaseEventBuilder.php | 23 ++- app/EventBuilder/CommentEventBuilder.php | 52 +++++- app/EventBuilder/EventIteratorBuilder.php | 48 +++++ app/EventBuilder/ProjectFileEventBuilder.php | 29 ++- app/EventBuilder/SubtaskEventBuilder.php | 48 ++++- app/EventBuilder/TaskEventBuilder.php | 102 +++++++++- app/EventBuilder/TaskFileEventBuilder.php | 38 +++- app/EventBuilder/TaskLinkEventBuilder.php | 2 +- app/Job/CommentEventJob.php | 2 +- app/Job/ProjectFileEventJob.php | 2 +- app/Job/SubtaskEventJob.php | 2 +- app/Job/TaskEventJob.php | 2 +- app/Job/TaskFileEventJob.php | 2 +- app/Job/TaskLinkEventJob.php | 2 +- app/Locale/bs_BA/translations.php | 6 +- app/Locale/cs_CZ/translations.php | 6 +- app/Locale/da_DK/translations.php | 6 +- app/Locale/de_DE/translations.php | 6 +- app/Locale/el_GR/translations.php | 6 +- app/Locale/es_ES/translations.php | 6 +- app/Locale/fi_FI/translations.php | 6 +- app/Locale/fr_FR/translations.php | 6 +- app/Locale/hu_HU/translations.php | 6 +- app/Locale/id_ID/translations.php | 6 +- app/Locale/it_IT/translations.php | 6 +- app/Locale/ja_JP/translations.php | 6 +- app/Locale/ko_KR/translations.php | 6 +- app/Locale/my_MY/translations.php | 6 +- app/Locale/nb_NO/translations.php | 6 +- app/Locale/nl_NL/translations.php | 6 +- app/Locale/pl_PL/translations.php | 6 +- app/Locale/pt_BR/translations.php | 6 +- app/Locale/pt_PT/translations.php | 6 +- app/Locale/ru_RU/translations.php | 6 +- app/Locale/sr_Latn_RS/translations.php | 6 +- app/Locale/sv_SE/translations.php | 6 +- app/Locale/th_TH/translations.php | 6 +- app/Locale/tr_TR/translations.php | 6 +- app/Locale/zh_CN/translations.php | 6 +- app/Model/NotificationModel.php | 176 ++++++------------ app/Template/event/task_assignee_change.php | 2 +- .../Action/TaskAssignCategoryLinkTest.php | 6 +- .../units/Action/TaskAssignColorLinkTest.php | 4 +- .../EventBuilder/CommentEventBuilderTest.php | 4 +- .../ProjectFileEventBuilderTest.php | 4 +- .../EventBuilder/SubtaskEventBuilderTest.php | 6 +- .../EventBuilder/TaskEventBuilderTest.php | 10 +- .../EventBuilder/TaskFileEventBuilderTest.php | 4 +- .../EventBuilder/TaskLinkEventBuilderTest.php | 6 +- 49 files changed, 494 insertions(+), 232 deletions(-) create mode 100644 app/EventBuilder/EventIteratorBuilder.php diff --git a/app/EventBuilder/BaseEventBuilder.php b/app/EventBuilder/BaseEventBuilder.php index c677563e5..5aa777a01 100644 --- a/app/EventBuilder/BaseEventBuilder.php +++ b/app/EventBuilder/BaseEventBuilder.php @@ -19,5 +19,26 @@ abstract class BaseEventBuilder extends Base * @access public * @return GenericEvent|null */ - abstract public function build(); + abstract public function buildEvent(); + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + abstract public function buildTitleWithAuthor($author, $eventName, array $eventData); + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + abstract public function buildTitleWithoutAuthor($eventName, array $eventData); } diff --git a/app/EventBuilder/CommentEventBuilder.php b/app/EventBuilder/CommentEventBuilder.php index 7b4060e41..ba5842a47 100644 --- a/app/EventBuilder/CommentEventBuilder.php +++ b/app/EventBuilder/CommentEventBuilder.php @@ -3,6 +3,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\CommentEvent; +use Kanboard\Model\CommentModel; /** * Class CommentEventBuilder @@ -32,7 +33,7 @@ class CommentEventBuilder extends BaseEventBuilder * @access public * @return CommentEvent|null */ - public function build() + public function buildEvent() { $comment = $this->commentModel->getById($this->commentId); @@ -45,4 +46,53 @@ class CommentEventBuilder extends BaseEventBuilder 'task' => $this->taskFinderModel->getDetails($comment['task_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case CommentModel::EVENT_UPDATE: + return e('%s updated a comment on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_CREATE: + return e('%s commented on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_DELETE: + return e('%s removed a comment on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_USER_MENTION: + return e('%s mentioned you in a comment on the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case CommentModel::EVENT_CREATE: + return e('New comment on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_UPDATE: + return e('Comment updated on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_DELETE: + return e('Comment removed on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_USER_MENTION: + return e('You were mentioned in a comment on the task #%d', $eventData['task']['id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/EventIteratorBuilder.php b/app/EventBuilder/EventIteratorBuilder.php new file mode 100644 index 000000000..afa146b67 --- /dev/null +++ b/app/EventBuilder/EventIteratorBuilder.php @@ -0,0 +1,48 @@ +builders[] = $builder; + return $this; + } + + public function rewind() { + $this->position = 0; + } + + /** + * @return BaseEventBuilder + */ + public function current() { + return $this->builders[$this->position]; + } + + public function key() { + return $this->position; + } + + public function next() { + ++$this->position; + } + + public function valid() { + return isset($this->builders[$this->position]); + } +} diff --git a/app/EventBuilder/ProjectFileEventBuilder.php b/app/EventBuilder/ProjectFileEventBuilder.php index 70514a99b..6698f78a2 100644 --- a/app/EventBuilder/ProjectFileEventBuilder.php +++ b/app/EventBuilder/ProjectFileEventBuilder.php @@ -33,7 +33,7 @@ class ProjectFileEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $file = $this->projectFileModel->getById($this->fileId); @@ -47,4 +47,31 @@ class ProjectFileEventBuilder extends BaseEventBuilder 'project' => $this->projectModel->getById($file['project_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + return ''; + } } diff --git a/app/EventBuilder/SubtaskEventBuilder.php b/app/EventBuilder/SubtaskEventBuilder.php index f02712576..5f7e831d3 100644 --- a/app/EventBuilder/SubtaskEventBuilder.php +++ b/app/EventBuilder/SubtaskEventBuilder.php @@ -4,6 +4,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\SubtaskEvent; use Kanboard\Event\GenericEvent; +use Kanboard\Model\SubtaskModel; /** * Class SubtaskEventBuilder @@ -59,7 +60,7 @@ class SubtaskEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $eventData = array(); $eventData['subtask'] = $this->subtaskModel->getById($this->subtaskId, true); @@ -76,4 +77,49 @@ class SubtaskEventBuilder extends BaseEventBuilder $eventData['task'] = $this->taskFinderModel->getDetails($eventData['subtask']['task_id']); return new SubtaskEvent($eventData); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case SubtaskModel::EVENT_UPDATE: + return e('%s updated a subtask for the task #%d', $author, $eventData['task']['id']); + case SubtaskModel::EVENT_CREATE: + return e('%s created a subtask for the task #%d', $author, $eventData['task']['id']); + case SubtaskModel::EVENT_DELETE: + return e('%s removed a subtask for the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case SubtaskModel::EVENT_CREATE: + return e('New subtask on task #%d', $eventData['subtask']['task_id']); + case SubtaskModel::EVENT_UPDATE: + return e('Subtask updated on task #%d', $eventData['subtask']['task_id']); + case SubtaskModel::EVENT_DELETE: + return e('Subtask removed on task #%d', $eventData['subtask']['task_id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/TaskEventBuilder.php b/app/EventBuilder/TaskEventBuilder.php index e7a5653dd..aa8976329 100644 --- a/app/EventBuilder/TaskEventBuilder.php +++ b/app/EventBuilder/TaskEventBuilder.php @@ -3,6 +3,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\TaskEvent; +use Kanboard\Model\TaskModel; /** * Class TaskEventBuilder @@ -98,7 +99,7 @@ class TaskEventBuilder extends BaseEventBuilder * @access public * @return TaskEvent|null */ - public function build() + public function buildEvent() { $eventData = array(); $eventData['task_id'] = $this->taskId; @@ -120,4 +121,103 @@ class TaskEventBuilder extends BaseEventBuilder return new TaskEvent(array_merge($eventData, $this->values)); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case TaskModel::EVENT_ASSIGNEE_CHANGE: + $assignee = $eventData['task']['assignee_name'] ?: $eventData['task']['assignee_username']; + + if (! empty($assignee)) { + return e('%s changed the assignee of the task #%d to %s', $author, $eventData['task']['id'], $assignee); + } + + return e('%s removed the assignee of the task %s', $author, e('#%d', $eventData['task']['id'])); + case TaskModel::EVENT_UPDATE: + return e('%s updated the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_CREATE: + return e('%s created the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_CLOSE: + return e('%s closed the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_OPEN: + return e('%s opened the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_MOVE_COLUMN: + return e( + '%s moved the task #%d to the column "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['column_title'] + ); + case TaskModel::EVENT_MOVE_POSITION: + return e( + '%s moved the task #%d to the position %d in the column "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['position'], + $eventData['task']['column_title'] + ); + case TaskModel::EVENT_MOVE_SWIMLANE: + if ($eventData['task']['swimlane_id'] == 0) { + return e('%s moved the task #%d to the first swimlane', $author, $eventData['task']['id']); + } + + return e( + '%s moved the task #%d to the swimlane "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['swimlane_name'] + ); + + case TaskModel::EVENT_USER_MENTION: + return e('%s mentioned you in the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case TaskModel::EVENT_CREATE: + return e('New task #%d: %s', $eventData['task']['id'], $eventData['task']['title']); + case TaskModel::EVENT_UPDATE: + return e('Task updated #%d', $eventData['task']['id']); + case TaskModel::EVENT_CLOSE: + return e('Task #%d closed', $eventData['task']['id']); + case TaskModel::EVENT_OPEN: + return e('Task #%d opened', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_COLUMN: + return e('Column changed for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_POSITION: + return e('New position for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_SWIMLANE: + return e('Swimlane changed for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_ASSIGNEE_CHANGE: + return e('Assignee changed on task #%d', $eventData['task']['id']); + case TaskModel::EVENT_OVERDUE: + $nb = count($eventData['tasks']); + return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $eventData['tasks'][0]['id']); + case TaskModel::EVENT_USER_MENTION: + return e('You were mentioned in the task #%d', $eventData['task']['id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/TaskFileEventBuilder.php b/app/EventBuilder/TaskFileEventBuilder.php index 7f1ce3b3f..8c985cc03 100644 --- a/app/EventBuilder/TaskFileEventBuilder.php +++ b/app/EventBuilder/TaskFileEventBuilder.php @@ -4,6 +4,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\TaskFileEvent; use Kanboard\Event\GenericEvent; +use Kanboard\Model\TaskFileModel; /** * Class TaskFileEventBuilder @@ -33,7 +34,7 @@ class TaskFileEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $file = $this->taskFileModel->getById($this->fileId); @@ -47,4 +48,39 @@ class TaskFileEventBuilder extends BaseEventBuilder 'task' => $this->taskFinderModel->getDetails($file['task_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + if ($eventName === TaskFileModel::EVENT_CREATE) { + return e('%s attached a file to the task #%d', $author, $eventData['task']['id']); + } + + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + if ($eventName === TaskFileModel::EVENT_CREATE) { + return e('New attachment on task #%d: %s', $eventData['file']['task_id'], $eventData['file']['name']); + } + + return ''; + } } diff --git a/app/EventBuilder/TaskLinkEventBuilder.php b/app/EventBuilder/TaskLinkEventBuilder.php index 8be5299f7..f1a3fba20 100644 --- a/app/EventBuilder/TaskLinkEventBuilder.php +++ b/app/EventBuilder/TaskLinkEventBuilder.php @@ -33,7 +33,7 @@ class TaskLinkEventBuilder extends BaseEventBuilder * @access public * @return TaskLinkEvent|null */ - public function build() + public function buildEvent() { $taskLink = $this->taskLinkModel->getById($this->taskLinkId); diff --git a/app/Job/CommentEventJob.php b/app/Job/CommentEventJob.php index c89350edd..47cf8020b 100644 --- a/app/Job/CommentEventJob.php +++ b/app/Job/CommentEventJob.php @@ -37,7 +37,7 @@ class CommentEventJob extends BaseJob { $event = CommentEventBuilder::getInstance($this->container) ->withCommentId($commentId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/ProjectFileEventJob.php b/app/Job/ProjectFileEventJob.php index d68949c52..45e6ece39 100644 --- a/app/Job/ProjectFileEventJob.php +++ b/app/Job/ProjectFileEventJob.php @@ -36,7 +36,7 @@ class ProjectFileEventJob extends BaseJob { $event = ProjectFileEventBuilder::getInstance($this->container) ->withFileId($fileId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/SubtaskEventJob.php b/app/Job/SubtaskEventJob.php index 1dc243ef2..85c4d73ec 100644 --- a/app/Job/SubtaskEventJob.php +++ b/app/Job/SubtaskEventJob.php @@ -39,7 +39,7 @@ class SubtaskEventJob extends BaseJob $event = SubtaskEventBuilder::getInstance($this->container) ->withSubtaskId($subtaskId) ->withValues($values) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/TaskEventJob.php b/app/Job/TaskEventJob.php index 46f7a16c2..7d026a682 100644 --- a/app/Job/TaskEventJob.php +++ b/app/Job/TaskEventJob.php @@ -47,7 +47,7 @@ class TaskEventJob extends BaseJob ->withChanges($changes) ->withValues($values) ->withTask($task) - ->build(); + ->buildEvent(); if ($event !== null) { foreach ($eventNames as $eventName) { diff --git a/app/Job/TaskFileEventJob.php b/app/Job/TaskFileEventJob.php index de2c40dbc..293dbf27c 100644 --- a/app/Job/TaskFileEventJob.php +++ b/app/Job/TaskFileEventJob.php @@ -36,7 +36,7 @@ class TaskFileEventJob extends BaseJob { $event = TaskFileEventBuilder::getInstance($this->container) ->withFileId($fileId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/TaskLinkEventJob.php b/app/Job/TaskLinkEventJob.php index 669608ada..31f62f07b 100644 --- a/app/Job/TaskLinkEventJob.php +++ b/app/Job/TaskLinkEventJob.php @@ -36,7 +36,7 @@ class TaskLinkEventJob extends BaseJob { $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId($taskLinkId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php index 6a0620682..f1529e027 100644 --- a/app/Locale/bs_BA/translations.php +++ b/app/Locale/bs_BA/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ažurirao zadatak #%d', '%s created the task #%d' => '%s kreirao zadatak #%d', '%s closed the task #%d' => '%s zatvorio zadatak #%d', - '%s open the task #%d' => '%s otvorio zadatak #%d', + '%s opened the task #%d' => '%s otvorio zadatak #%d', '%s moved the task #%d to the column "%s"' => '%s premjestio zadatak #%d u kolonu "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s premjestio zadatak #%d na poziciju %d u koloni "%s"', 'Activity' => 'Aktivnosti', 'Default values are "%s"' => 'Podrazumijevane vrijednosti su: "%s"', 'Default columns for new projects (Comma-separated)' => 'Podrazumijevane kolone za novi projekat (Odvojene zarezom)', 'Task assignee change' => 'Promijena izvršioca zadatka', - '%s change the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s', + '%s changed the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s', '%s changed the assignee of the task %s to %s' => '%s promijenio izvršioca za zadatak %s u %s', 'New password for the user "%s"' => 'Nova šifra korisnika "%s"', 'Choose an event' => 'Izaberi događaj', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Stopa valute je uspješno dodana.', 'Unable to add this currency rate.' => 'Nemoguće dodati stopu valute.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s', + '%s removed the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s', 'Enable Gravatar images' => 'Omogući Gravatar slike', 'Information' => 'Informacije', 'Check two factor authentication code' => 'Provjera faktor-dva autentifikacionog koda', diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index b9a4de6ed..c7e6e536d 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s aktualizoval úkol #%d ', '%s created the task #%d' => '%s vytvořil úkol #%d ', '%s closed the task #%d' => '%s uzavřel úkol #%d ', - '%s open the task #%d' => '%s znovu otevřel úkol #%d ', + '%s opened the task #%d' => '%s znovu otevřel úkol #%d ', '%s moved the task #%d to the column "%s"' => '%s přesunul úkol #%d do sloupce "%s" ', '%s moved the task #%d to the position %d in the column "%s"' => '%s přesunul úkol #%d na pozici %d ve sloupci "%s" ', 'Activity' => 'Aktivity', 'Default values are "%s"' => 'Standardní hodnoty jsou: "%s"', 'Default columns for new projects (Comma-separated)' => 'Výchozí sloupce pro nové projekty (odděleny čárkou)', 'Task assignee change' => 'Změna přiřazení uživatelů', - '%s change the assignee of the task #%d to %s' => '%s změnil přidělení úkolu #%d na uživatele %s', + '%s changed the assignee of the task #%d to %s' => '%s změnil přidělení úkolu #%d na uživatele %s', '%s changed the assignee of the task %s to %s' => '%s změnil přidělení úkolu %s na uživatele %s', 'New password for the user "%s"' => 'Nové heslo pro uživatele "%s"', 'Choose an event' => 'Vybrat událost', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Směnný kurz byl úspěšně přidán.', 'Unable to add this currency rate.' => 'Nelze přidat tento směnný kurz', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s odstranil přiřazení úkolu %s ', + '%s removed the assignee of the task %s' => '%s odstranil přiřazení úkolu %s ', 'Enable Gravatar images' => 'Aktiviere Gravatar Bilder', 'Information' => 'Informace', 'Check two factor authentication code' => 'Zkontrolujte dvouúrovňový autentifikační klíč', diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index 050a37d93..6cecfaeca 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s opdaterede opgaven #%d', '%s created the task #%d' => '%s oprettede opgaven #%d', '%s closed the task #%d' => '%s lukkede opgaven #%d', - '%s open the task #%d' => '%s åbnede opgaven #%d', + '%s opened the task #%d' => '%s åbnede opgaven #%d', '%s moved the task #%d to the column "%s"' => '%s flyttede opgaven #%d til kolonnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttede opgaven #%d til position %d i kolonnen "%s"', 'Activity' => 'Aktivitet', 'Default values are "%s"' => 'Standard værdier er "%s"', 'Default columns for new projects (Comma-separated)' => 'Standard kolonne for nye projekter (kommasepareret)', 'Task assignee change' => 'Opgaven ansvarlig ændring', - '%s change the assignee of the task #%d to %s' => '%s skrift ansvarlig for opgaven #%d til %s', + '%s changed the assignee of the task #%d to %s' => '%s skrift ansvarlig for opgaven #%d til %s', '%s changed the assignee of the task %s to %s' => '%s skift ansvarlig for opgaven %s til %s', 'New password for the user "%s"' => 'Ny adgangskode for brugeren "%s"', 'Choose an event' => 'Vælg et event', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index d6c8bf607..d25e7e8a8 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s hat die Aufgabe #%d aktualisiert', '%s created the task #%d' => '%s hat die Aufgabe #%d angelegt', '%s closed the task #%d' => '%s hat die Aufgabe #%d geschlossen', - '%s open the task #%d' => '%s hat die Aufgabe #%d geöffnet', + '%s opened the task #%d' => '%s hat die Aufgabe #%d geöffnet', '%s moved the task #%d to the column "%s"' => '%s hat die Aufgabe #%d in die Spalte "%s" verschoben', '%s moved the task #%d to the position %d in the column "%s"' => '%s hat die Aufgabe #%d an die Position %d in der Spalte "%s" verschoben', 'Activity' => 'Aktivität', 'Default values are "%s"' => 'Die Standardwerte sind "%s"', 'Default columns for new projects (Comma-separated)' => 'Standardspalten für neue Projekte (komma-getrennt)', 'Task assignee change' => 'Zuständigkeit geändert', - '%s change the assignee of the task #%d to %s' => '%s hat die Zusständigkeit der Aufgabe #%d geändert um %s', + '%s changed the assignee of the task #%d to %s' => '%s hat die Zusständigkeit der Aufgabe #%d geändert um %s', '%s changed the assignee of the task %s to %s' => '%s hat die Zuständigkeit der Aufgabe %s geändert um %s', 'New password for the user "%s"' => 'Neues Passwort des Benutzers "%s"', 'Choose an event' => 'Aktion wählen', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Der Währungskurs wurde erfolgreich hinzugefügt.', 'Unable to add this currency rate.' => 'Währungskurs konnte nicht hinzugefügt werden', 'Webhook URL' => 'Webhook-URL', - '%s remove the assignee of the task %s' => '%s Zuordnung für die Aufgabe %s entfernen', + '%s removed the assignee of the task %s' => '%s Zuordnung für die Aufgabe %s entfernen', 'Enable Gravatar images' => 'Aktiviere Gravatar-Bilder', 'Information' => 'Information', 'Check two factor authentication code' => 'Prüfe Zwei-Faktor-Authentifizierungscode', diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php index 87ea68b0e..b02207d51 100644 --- a/app/Locale/el_GR/translations.php +++ b/app/Locale/el_GR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ενημέρωσε την εργασία n°%d', '%s created the task #%d' => '%s δημιούργησε την εργασία n°%d', '%s closed the task #%d' => '%s έκλεισε την εργασία n°%d', - '%s open the task #%d' => '%s άνοιξε την εργασία n°%d', + '%s opened the task #%d' => '%s άνοιξε την εργασία n°%d', '%s moved the task #%d to the column "%s"' => '%s μετακίνησε την εργασία n°%d στη στήλη « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s μετακίνησε την εργασία n°%d στη θέση n°%d της στήλης « %s »', 'Activity' => 'Δραστηριότητα', 'Default values are "%s"' => 'Οι προεπιλεγμένες τιμές είναι « %s »', 'Default columns for new projects (Comma-separated)' => 'Προεπιλεγμένες στήλες για νέα έργα (Comma-separated)', 'Task assignee change' => 'Αλλαγή εκδοχέα εργασίας', - '%s change the assignee of the task #%d to %s' => '%s άλλαξε τον εκδοχέα της εργασίας n˚%d σε %s', + '%s changed the assignee of the task #%d to %s' => '%s άλλαξε τον εκδοχέα της εργασίας n˚%d σε %s', '%s changed the assignee of the task %s to %s' => '%s ενημέρωσε τον εκδοχέα της εργασίας %s σε %s', 'New password for the user "%s"' => 'Νέο password του χρήστη « %s »', 'Choose an event' => 'Επιλογή event', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Η ισοτιμία προστέθηκε με επιτυχία.', 'Unable to add this currency rate.' => 'Αδύνατο να προστεθεί αυτή η ισοτιμία.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s αφαίρεσε τον εκδοχέα της εργασίας %s', + '%s removed the assignee of the task %s' => '%s αφαίρεσε τον εκδοχέα της εργασίας %s', 'Enable Gravatar images' => 'Ενεργοποίηση εικόνων Gravatar', 'Information' => 'Πληροφορίες', 'Check two factor authentication code' => 'Ελέγξτε δύο παράγοντες ελέγχου ταυτότητας κωδικού', diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index 1a4bae82b..fa59ca07e 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s actualizó la tarea #%d', '%s created the task #%d' => '%s creó la tarea #%d', '%s closed the task #%d' => '%s cerró la tarea #%d', - '%s open the task #%d' => '%s abrió la tarea #%d', + '%s opened the task #%d' => '%s abrió la tarea #%d', '%s moved the task #%d to the column "%s"' => '%s movió la tarea #%d a la columna «%s»', '%s moved the task #%d to the position %d in the column "%s"' => '%s movió la tarea #%d a la posición %d de la columna «%s»', 'Activity' => 'Actividad', 'Default values are "%s"' => 'Los valores por defecto son «%s»', 'Default columns for new projects (Comma-separated)' => 'Columnas por defecto para los nuevos proyectos (separadas mediante comas)', 'Task assignee change' => 'Cambiar responsable de la tarea', - '%s change the assignee of the task #%d to %s' => '%s cambió el responsable de la tarea #%d por %s', + '%s changed the assignee of the task #%d to %s' => '%s cambió el responsable de la tarea #%d por %s', '%s changed the assignee of the task %s to %s' => '%s cambió el responsable de la tarea %s por %s', 'New password for the user "%s"' => 'Nueva contraseña para el usuario «%s»', 'Choose an event' => 'Seleccione un evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'El cambio de moneda se ha añadido correctamente.', 'Unable to add this currency rate.' => 'No se puede añadir este cambio de moneda.', 'Webhook URL' => 'URL del disparador web (webhook)', - '%s remove the assignee of the task %s' => '%s quita el responsable de la tarea %s', + '%s removed the assignee of the task %s' => '%s quita el responsable de la tarea %s', 'Enable Gravatar images' => 'Activar imágenes Gravatar', 'Information' => 'Información', 'Check two factor authentication code' => 'Revisar código de autenticación en dos pasos', diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 5d37cb820..200a9cded 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s päivitti tehtävää #%d', '%s created the task #%d' => '%s loi tehtävän #%d', '%s closed the task #%d' => '%s sulki tehtävän #%d', - '%s open the task #%d' => '%s avasi tehtävän #%d', + '%s opened the task #%d' => '%s avasi tehtävän #%d', '%s moved the task #%d to the column "%s"' => '%s siirsi tehtävän #%d sarakkeeseen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s siirsi tehtävän #%d %d. sarakkeessa %s', 'Activity' => 'Toiminta', 'Default values are "%s"' => 'Oletusarvot ovat "%s"', 'Default columns for new projects (Comma-separated)' => 'Oletussarakkeet uusille projekteille', 'Task assignee change' => 'Tehtävän saajan vaihto', - '%s change the assignee of the task #%d to %s' => '%s vaihtoi tehtävän #%d saajaksi %s', + '%s changed the assignee of the task #%d to %s' => '%s vaihtoi tehtävän #%d saajaksi %s', '%s changed the assignee of the task %s to %s' => '%s vaihtoi tehtävän %s saajaksi %s', 'New password for the user "%s"' => 'Uusi salasana käyttäjälle "%s"', 'Choose an event' => 'Valitse toiminta', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index c8f7d343d..9f6cf971d 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s a mis à jour la tâche n°%d', '%s created the task #%d' => '%s a créé la tâche n°%d', '%s closed the task #%d' => '%s a fermé la tâche n°%d', - '%s open the task #%d' => '%s a ouvert la tâche n°%d', + '%s opened the task #%d' => '%s a ouvert la tâche n°%d', '%s moved the task #%d to the column "%s"' => '%s a déplacé la tâche n°%d dans la colonne « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s a déplacé la tâche n°%d à la position n°%d dans la colonne « %s »', 'Activity' => 'Activité', 'Default values are "%s"' => 'Les valeurs par défaut sont « %s »', 'Default columns for new projects (Comma-separated)' => 'Colonnes par défaut pour les nouveaux projets (séparation par des virgules)', 'Task assignee change' => 'Modification de la personne assignée à une tâche', - '%s change the assignee of the task #%d to %s' => '%s a changé la personne assignée à la tâche n˚%d pour %s', + '%s changed the assignee of the task #%d to %s' => '%s a changé la personne assignée à la tâche n˚%d pour %s', '%s changed the assignee of the task %s to %s' => '%s a changé la personne assignée à la tâche %s pour %s', 'New password for the user "%s"' => 'Nouveau mot de passe pour l\'utilisateur « %s »', 'Choose an event' => 'Choisir un événement', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Le taux de change a été ajouté avec succès.', 'Unable to add this currency rate.' => 'Impossible d\'ajouter ce taux de change', 'Webhook URL' => 'URL du webhook', - '%s remove the assignee of the task %s' => '%s a enlevé la personne assignée à la tâche %s', + '%s removed the assignee of the task %s' => '%s a enlevé la personne assignée à la tâche %s', 'Enable Gravatar images' => 'Activer les images Gravatar', 'Information' => 'Informations', 'Check two factor authentication code' => 'Vérification du code pour l\'authentification à deux-facteurs', diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index febf8bc02..781a04234 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s frissítette a feladatot #%d', '%s created the task #%d' => '%s létrehozta a feladatot #%d', '%s closed the task #%d' => '%s lezárta a feladatot #%d', - '%s open the task #%d' => '%s megnyitotta a feladatot #%d', + '%s opened the task #%d' => '%s megnyitotta a feladatot #%d', '%s moved the task #%d to the column "%s"' => '%s átmozgatta a feladatot #%d a "%s" oszlopba', '%s moved the task #%d to the position %d in the column "%s"' => '%s átmozgatta a feladatot #%d a %d pozícióba a "%s" oszlopban', 'Activity' => 'Tevékenységek', 'Default values are "%s"' => 'Az alapértelmezett értékek: %s', 'Default columns for new projects (Comma-separated)' => 'Alapértelmezett oszlopok az új projektekben (vesszővel elválasztva)', 'Task assignee change' => 'Felelős módosítása', - '%s change the assignee of the task #%d to %s' => '%s a felelőst módosította #%d %s', + '%s changed the assignee of the task #%d to %s' => '%s a felelőst módosította #%d %s', '%s changed the assignee of the task %s to %s' => '%s a felelőst %s módosította: %s', 'New password for the user "%s"' => 'Felhasználó új jelszava: %s', 'Choose an event' => 'Válasszon eseményt', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Az átváltási árfolyammal történő bővítés sikerült', 'Unable to add this currency rate.' => 'Nem sikerült az átváltási árfolyam felvétele', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s eltávolította a %s feladathoz rendelt személyt', + '%s removed the assignee of the task %s' => '%s eltávolította a %s feladathoz rendelt személyt', 'Enable Gravatar images' => 'Gravatár képek engedélyezése', 'Information' => 'Információ', 'Check two factor authentication code' => 'Két fázisú beléptető kód ellenőrzése', diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 18a7a72dd..26e091ceb 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s memperbaharui tugas n°%d', '%s created the task #%d' => '%s membuat tugas n°%d', '%s closed the task #%d' => '%s menutup tugas n°%d', - '%s open the task #%d' => '%s membuka tugas n°%d', + '%s opened the task #%d' => '%s membuka tugas n°%d', '%s moved the task #%d to the column "%s"' => '%s memindahkan tugas n°%d ke kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s memindahkan tugas n°%d ke posisi n°%d dalam kolom « %s »', 'Activity' => 'Aktifitas', 'Default values are "%s"' => 'Standar nilai adalah« %s »', 'Default columns for new projects (Comma-separated)' => 'Kolom default untuk proyek baru (dipisahkan dengan koma)', 'Task assignee change' => 'Mengubah orang ditugaskan untuk tugas', - '%s change the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', + '%s changed the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', '%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s', 'New password for the user "%s"' => 'Kata sandi baru untuk pengguna « %s »', 'Choose an event' => 'Pilih acara', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Nilai tukar mata uang berhasil ditambahkan.', 'Unable to add this currency rate.' => 'Tidak dapat menambahkan nilai tukar mata uang', 'Webhook URL' => 'URL webhook', - '%s remove the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', + '%s removed the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', 'Enable Gravatar images' => 'Mengaktifkan gambar Gravatar', 'Information' => 'Informasi', 'Check two factor authentication code' => 'Cek dua faktor kode otentifikasi', diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index f6c630760..aadbfe5b0 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ha aggiornato il task #%d', '%s created the task #%d' => '%s ha creato il task #%d', '%s closed the task #%d' => '%s ha chiuso il task #%d', - '%s open the task #%d' => '%s ha aperto il task #%d', + '%s opened the task #%d' => '%s ha aperto il task #%d', '%s moved the task #%d to the column "%s"' => '%s ha spostato il task #%d nella colonna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s ha spostato il task #%d nella posizione %d della colonna "%s"', 'Activity' => 'Attività', 'Default values are "%s"' => 'Valori di default "%s"', 'Default columns for new projects (Comma-separated)' => 'Colonne di default per i nuovi progetti (Separati da virgola)', 'Task assignee change' => 'Cambia l\'assegnatario del task', - '%s change the assignee of the task #%d to %s' => '%s dai l\'assegnazione del task #%d a %s', + '%s changed the assignee of the task #%d to %s' => '%s dai l\'assegnazione del task #%d a %s', '%s changed the assignee of the task %s to %s' => '%s ha cambiato l\'assegnatario del task %s a %s', 'New password for the user "%s"' => 'Nuova password per l\'utente "%s"', 'Choose an event' => 'Scegli un evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Il tasso di cambio è stato aggiunto con successo.', 'Unable to add this currency rate.' => 'Impossibile aggiungere questo tasso di cambio.', 'Webhook URL' => 'URL Webhook', - '%s remove the assignee of the task %s' => '%s rimuove l\'assegnatario del task %s', + '%s removed the assignee of the task %s' => '%s rimuove l\'assegnatario del task %s', 'Enable Gravatar images' => 'Abilita immagini Gravatar', 'Information' => 'Informazioni', 'Check two factor authentication code' => 'Controlla il codice di autenticazione "two-factor"', diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index dab731d27..03fa55ed7 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s がタスク #%d を更新しました', '%s created the task #%d' => '%s がタスク #%d を追加しました', '%s closed the task #%d' => '%s がタスク #%d をクローズしました', - '%s open the task #%d' => '%s がタスク #%d をオープンしました', + '%s opened the task #%d' => '%s がタスク #%d をオープンしました', '%s moved the task #%d to the column "%s"' => '%s がタスク #%d をカラム「%s」に移動しました', '%s moved the task #%d to the position %d in the column "%s"' => '%s がタスク #%d を位置 %d カラム「%s」移動しました', 'Activity' => 'アクティビティ', 'Default values are "%s"' => 'デフォルト値は「%s」', 'Default columns for new projects (Comma-separated)' => '新規プロジェクトのデフォルトカラム (コンマで区切って入力)', 'Task assignee change' => '担当者の変更', - '%s change the assignee of the task #%d to %s' => '%s がタスク #%d の担当を %s に変更しました', + '%s changed the assignee of the task #%d to %s' => '%s がタスク #%d の担当を %s に変更しました', '%s changed the assignee of the task %s to %s' => '%s がタスク %s の担当を %s に変更しました', 'New password for the user "%s"' => 'ユーザ「%s」の新しいパスワード', 'Choose an event' => 'イベントの選択', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', 'Unable to add this currency rate.' => 'この通貨レートを追加できません。', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s がタスク「%s」の担当を解除しました。', + '%s removed the assignee of the task %s' => '%s がタスク「%s」の担当を解除しました。', 'Enable Gravatar images' => 'Gravatar イメージを有効化', 'Information' => '情報 ', 'Check two factor authentication code' => '2 段認証をチェックする', diff --git a/app/Locale/ko_KR/translations.php b/app/Locale/ko_KR/translations.php index 0b6007b19..bf140d940 100644 --- a/app/Locale/ko_KR/translations.php +++ b/app/Locale/ko_KR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s이 할일#%d을 갱신했습니다', '%s created the task #%d' => '%s이 할일#%d을 추가했습니다', '%s closed the task #%d' => '%s이 할일#%d을 닫혔습니다', - '%s open the task #%d' => '%s이 할일#%d를 오픈했습니다', + '%s opened the task #%d' => '%s이 할일#%d를 오픈했습니다', '%s moved the task #%d to the column "%s"' => '%s이 할일#%d을 칼럼"%s"로 옮겼습니다', '%s moved the task #%d to the position %d in the column "%s"' => '%s이 할일#%d을 칼럼 "%s"의 %d 위치로 이동시켰습니다', 'Activity' => '활동', 'Default values are "%s"' => '기본 값은 "%s" 입니다', 'Default columns for new projects (Comma-separated)' => '새로운 프로젝트의 기본 칼럼 (콤마(,)로 분리됨)', 'Task assignee change' => '담당자의 변경', - '%s change the assignee of the task #%d to %s' => '%s이 할일 #%d의 담당을 %s로 변경합니다', + '%s changed the assignee of the task #%d to %s' => '%s이 할일 #%d의 담당을 %s로 변경합니다', '%s changed the assignee of the task %s to %s' => '%s이 할일 %s의 담당을 %s로 변경했습니다', 'New password for the user "%s"' => '사용자 "%s"의 새로운 패스워드', 'Choose an event' => '행사의 선택', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => '통화가 성공적으로 추가되었습니다', 'Unable to add this currency rate.' => '이 통화 환율을 추가할 수 없습니다.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s이 할일 %s의 담당을 삭제했습니다', + '%s removed the assignee of the task %s' => '%s이 할일 %s의 담당을 삭제했습니다', 'Enable Gravatar images' => 'Gravatar이미지를 활성화', 'Information' => '정보', 'Check two factor authentication code' => '2단 인증을 체크한다', diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php index 3d66b0bb4..cf4f399c0 100644 --- a/app/Locale/my_MY/translations.php +++ b/app/Locale/my_MY/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s memperbaharui tugas n°%d', '%s created the task #%d' => '%s membuat tugas n°%d', '%s closed the task #%d' => '%s menutup tugas n°%d', - '%s open the task #%d' => '%s membuka tugas n°%d', + '%s opened the task #%d' => '%s membuka tugas n°%d', '%s moved the task #%d to the column "%s"' => '%s memindahkan tugas n°%d ke kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s memindahkan tugas n°%d ke posisi n°%d dalam kolom « %s »', 'Activity' => 'Aktifitas', 'Default values are "%s"' => 'Standar nilai adalah« %s »', 'Default columns for new projects (Comma-separated)' => 'Kolom default untuk projek baru (dipisahkan dengan koma)', 'Task assignee change' => 'Mengubah orang ditugaskan untuk tugas', - '%s change the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', + '%s changed the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', '%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s', 'New password for the user "%s"' => 'Kata laluan baru untuk pengguna « %s »', 'Choose an event' => 'Pilih sebuah acara', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Nilai tukar mata uang berhasil ditambahkan.', 'Unable to add this currency rate.' => 'Tidak dapat menambahkan nilai tukar mata uang', 'Webhook URL' => 'URL webhook', - '%s remove the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', + '%s removed the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', 'Enable Gravatar images' => 'Mengaktifkan gambar Gravatar', 'Information' => 'Informasi', 'Check two factor authentication code' => 'Cek dua faktor kode otentifikasi', diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index 14e260cb9..ce69deb9e 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s oppdaterte oppgaven #%d', '%s created the task #%d' => '%s opprettet oppgaven #%d', '%s closed the task #%d' => '%s lukket oppgaven #%d', - '%s open the task #%d' => '%s åpnet oppgaven #%d', + '%s opened the task #%d' => '%s åpnet oppgaven #%d', '%s moved the task #%d to the column "%s"' => '%s flyttet oppgaven #%d til kolonnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttet oppgaven #%d til posisjonen %d i kolonnen "%s"', 'Activity' => 'Aktivitetslogg', 'Default values are "%s"' => 'Standardverdier er "%s"', 'Default columns for new projects (Comma-separated)' => 'Standard kolonne for nye prosjekter (komma-separert)', 'Task assignee change' => 'Endring av oppgaveansvarlig', - '%s change the assignee of the task #%d to %s' => '%s endre ansvarlig for oppgaven #%d til %s', + '%s changed the assignee of the task #%d to %s' => '%s endre ansvarlig for oppgaven #%d til %s', '%s changed the assignee of the task %s to %s' => '%s endret ansvarlig for oppgaven %s til %s', 'New password for the user "%s"' => 'Nytt passord for brukeren "%s"', 'Choose an event' => 'Velg en hendelse', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index 8b47d5149..d5ba70367 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s heeft taak %d aangepast', '%s created the task #%d' => '%s heeft taak %d aangemaakt', '%s closed the task #%d' => '%s heeft taak %d gesloten', - '%s open the task #%d' => '%s a heeft taak %d geopend', + '%s opened the task #%d' => '%s a heeft taak %d geopend', '%s moved the task #%d to the column "%s"' => '%s heeft taak %d verplaatst naar kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s heeft taak %d verplaatst naar positie %d in kolom « %s »', 'Activity' => 'Activiteit', 'Default values are "%s"' => 'Standaardwaarden zijn « %s »', 'Default columns for new projects (Comma-separated)' => 'Standaard kolommen voor nieuw projecten (komma gescheiden)', 'Task assignee change' => 'Taak toegewezene verandering', - '%s change the assignee of the task #%d to %s' => '%s heeft de toegewezene voor taak %d veranderd in %s', + '%s changed the assignee of the task #%d to %s' => '%s heeft de toegewezene voor taak %d veranderd in %s', '%s changed the assignee of the task %s to %s' => '%s heeft de toegewezene voor taak %s veranderd in %s', 'New password for the user "%s"' => 'Nieuw wachtwoord voor gebruiker « %s »', 'Choose an event' => 'Kies een gebeurtenis', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', 'Webhook URL' => 'Webhook URL', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index e72649e6f..f2570d7cc 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s zaktualizował zadanie #%d', '%s created the task #%d' => '%s utworzył zadanie #%d', '%s closed the task #%d' => '%s zamknął zadanie #%d', - '%s open the task #%d' => '%s otworzył zadanie #%d', + '%s opened the task #%d' => '%s otworzył zadanie #%d', '%s moved the task #%d to the column "%s"' => '%s przeniósł zadanie #%d do kolumny "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s przeniósł zadanie #%d na pozycję %d w kolmnie "%s"', 'Activity' => 'Aktywność', 'Default values are "%s"' => 'Domyślne wartości: "%s"', 'Default columns for new projects (Comma-separated)' => 'Domyślne kolumny dla nowych projektów (oddzielone przecinkiem)', 'Task assignee change' => 'Zmień osobę odpowiedzialną', - '%s change the assignee of the task #%d to %s' => '%s zmienił osobę odpowiedzialną za zadanie #%d na %s', + '%s changed the assignee of the task #%d to %s' => '%s zmienił osobę odpowiedzialną za zadanie #%d na %s', '%s changed the assignee of the task %s to %s' => '%s zmienił osobę odpowiedzialną za zadanie %s na %s', 'New password for the user "%s"' => 'Nowe hasło użytkownika "%s"', 'Choose an event' => 'Wybierz zdarzenie', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Dodano kurs waluty', 'Unable to add this currency rate.' => 'Nie można dodać kursu waluty', 'Webhook URL' => 'Adres webhooka', - '%s remove the assignee of the task %s' => '%s usunął osobę przypisaną do zadania %s', + '%s removed the assignee of the task %s' => '%s usunął osobę przypisaną do zadania %s', 'Enable Gravatar images' => 'Włącz Gravatar', 'Information' => 'Informacje', 'Check two factor authentication code' => 'Sprawdź kod weryfikujący', diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 7b64f0e74..46749043c 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s atualizou a tarefa #%d', '%s created the task #%d' => '%s criou a tarefa #%d', '%s closed the task #%d' => '%s finalizou a tarefa #%d', - '%s open the task #%d' => '%s abriu a tarefa #%d', + '%s opened the task #%d' => '%s abriu a tarefa #%d', '%s moved the task #%d to the column "%s"' => '%s moveu a tarefa #%d para a coluna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s moveu a tarefa #%d para a posição %d na coluna "%s"', 'Activity' => 'Atividade', 'Default values are "%s"' => 'Os valores padrão são "%s"', 'Default columns for new projects (Comma-separated)' => 'Colunas padrão para novos projetos (Separado por vírgula)', 'Task assignee change' => 'Mudar designação da tarefa', - '%s change the assignee of the task #%d to %s' => '%s mudou a designação da tarefa #%d para %s', + '%s changed the assignee of the task #%d to %s' => '%s mudou a designação da tarefa #%d para %s', '%s changed the assignee of the task %s to %s' => '%s mudou a designação da tarefa %s para %s', 'New password for the user "%s"' => 'Nova senha para o usuário "%s"', 'Choose an event' => 'Escolher um evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'A taxa de câmbio foi adicionada com sucesso.', 'Unable to add this currency rate.' => 'Impossível de adicionar essa taxa de câmbio.', 'Webhook URL' => 'URL do webhook', - '%s remove the assignee of the task %s' => '%s removeu a pessoa designada para a tarefa %s', + '%s removed the assignee of the task %s' => '%s removeu a pessoa designada para a tarefa %s', 'Enable Gravatar images' => 'Ativar imagens do Gravatar', 'Information' => 'Informações', 'Check two factor authentication code' => 'Verifique o código de autenticação em duas etapas', diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 5267b03ba..4fd070d12 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s actualizou a tarefa #%d', '%s created the task #%d' => '%s criou a tarefa #%d', '%s closed the task #%d' => '%s finalizou a tarefa #%d', - '%s open the task #%d' => '%s abriu a tarefa #%d', + '%s opened the task #%d' => '%s abriu a tarefa #%d', '%s moved the task #%d to the column "%s"' => '%s moveu a tarefa #%d para a coluna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s moveu a tarefa #%d para a posição %d na coluna "%s"', 'Activity' => 'Actividade', 'Default values are "%s"' => 'Os valores padrão são "%s"', 'Default columns for new projects (Comma-separated)' => 'Colunas padrão para novos projectos (Separado por vírgula)', 'Task assignee change' => 'Mudar assignação da tarefa', - '%s change the assignee of the task #%d to %s' => '%s mudou a assignação da tarefa #%d para %s', + '%s changed the assignee of the task #%d to %s' => '%s mudou a assignação da tarefa #%d para %s', '%s changed the assignee of the task %s to %s' => '%s mudou a assignação da tarefa %s para %s', 'New password for the user "%s"' => 'Nova senha para o utilizador "%s"', 'Choose an event' => 'Escolher um evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'A taxa de câmbio foi adicionada com sucesso.', 'Unable to add this currency rate.' => 'Impossível adicionar essa taxa de câmbio.', 'Webhook URL' => 'URL do webhook', - '%s remove the assignee of the task %s' => '%s removeu a pessoa assignada à tarefa %s', + '%s removed the assignee of the task %s' => '%s removeu a pessoa assignada à tarefa %s', 'Enable Gravatar images' => 'Activar imagem Gravatar', 'Information' => 'Informações', 'Check two factor authentication code' => 'Verificação do código de autenticação com factor duplo', diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index b3682f03f..92fba163b 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s обновил задачу #%d', '%s created the task #%d' => '%s создал задачу #%d', '%s closed the task #%d' => '%s закрыл задачу #%d', - '%s open the task #%d' => '%s открыл задачу #%d', + '%s opened the task #%d' => '%s открыл задачу #%d', '%s moved the task #%d to the column "%s"' => '%s переместил задачу #%d в колонку "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s переместил задачу #%d на позицию %d в колонке "%s"', 'Activity' => 'Активность', 'Default values are "%s"' => 'Колонки по умолчанию: "%s"', 'Default columns for new projects (Comma-separated)' => 'Колонки по умолчанию для новых проектов (разделять запятой)', 'Task assignee change' => 'Изменен назначенный', - '%s change the assignee of the task #%d to %s' => '%s сменил назначенного для задачи #%d на %s', + '%s changed the assignee of the task #%d to %s' => '%s сменил назначенного для задачи #%d на %s', '%s changed the assignee of the task %s to %s' => '%s сменил назначенного для задачи %s на %s', 'New password for the user "%s"' => 'Новый пароль для пользователя "%s"', 'Choose an event' => 'Выберите событие', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Курс валюты был успешно добавлен.', 'Unable to add this currency rate.' => 'Невозможно добавить этот курс валюты.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s удалить назначенную задачу %s', + '%s removed the assignee of the task %s' => '%s удалить назначенную задачу %s', 'Enable Gravatar images' => 'Включить Gravatar изображения', 'Information' => 'Информация', 'Check two factor authentication code' => 'Проверка кода двухфакторной авторизации', diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 157d9e2db..6a4bfc687 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s izmenjen zadatak #%d', '%s created the task #%d' => '%s kreirao zadatak #%d', '%s closed the task #%d' => '%s zatvorio zadatak #%d', - '%s open the task #%d' => '%s otvorio zadatak #%d', + '%s opened the task #%d' => '%s otvorio zadatak #%d', '%s moved the task #%d to the column "%s"' => '%s premestio zadatak #%d u kolonu "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s premestio zadatak #%d na pozycję %d w kolmnie "%s"', 'Activity' => 'Aktivnosti', 'Default values are "%s"' => 'Osnovne vrednosti su: "%s"', 'Default columns for new projects (Comma-separated)' => 'Osnovne kolone za novi projekat (Odvojeni zarezom)', 'Task assignee change' => 'Zmień osobę odpowiedzialną', - '%s change the assignee of the task #%d to %s' => '%s zamena dodele za zadatak #%d na %s', + '%s changed the assignee of the task #%d to %s' => '%s zamena dodele za zadatak #%d na %s', '%s changed the assignee of the task %s to %s' => '%s zamena dodele za zadatak %s na %s', 'New password for the user "%s"' => 'Nova lozinka za korisnika "%s"', 'Choose an event' => 'Izaberi događaj', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index e42a801db..7eb46a980 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s uppdaterade uppgiften #%d', '%s created the task #%d' => '%s skapade uppgiften #%d', '%s closed the task #%d' => '%s stängde uppgiften #%d', - '%s open the task #%d' => '%s öppnade uppgiften #%d', + '%s opened the task #%d' => '%s öppnade uppgiften #%d', '%s moved the task #%d to the column "%s"' => '%s flyttade uppgiften #%d till kolumnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttade uppgiften #%d till positionen %d i kolumnen "%s"', 'Activity' => 'Aktivitet', 'Default values are "%s"' => 'Standardvärden är "%s"', 'Default columns for new projects (Comma-separated)' => 'Standardkolumner för nya projekt (kommaseparerade)', 'Task assignee change' => 'Ändra tilldelning av uppgiften', - '%s change the assignee of the task #%d to %s' => '%s byt tilldelning av uppgiften #%d till %s', + '%s changed the assignee of the task #%d to %s' => '%s byt tilldelning av uppgiften #%d till %s', '%s changed the assignee of the task %s to %s' => '%s byt tilldelning av uppgiften %s till %s', 'New password for the user "%s"' => 'Nytt lösenord för användaren "%s"', 'Choose an event' => 'Välj en händelse', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Valutakursen har lagts till.', 'Unable to add this currency rate.' => 'Kunde inte lägga till valutakursen.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s ta bort tilldelningen av uppgiften %s', + '%s removed the assignee of the task %s' => '%s ta bort tilldelningen av uppgiften %s', 'Enable Gravatar images' => 'Aktivera Gravatar bilder', 'Information' => 'Information', 'Check two factor authentication code' => 'Kolla tvåfaktorsverifieringskod', diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 56adbdb8c..659797532 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ปรับปรุงงานแล้ว #%d', '%s created the task #%d' => '%s สร้างงานแล้ว #%d', '%s closed the task #%d' => '%s ปิดงานแล้ว #%d', - '%s open the task #%d' => '%s เปิดงานแล้ว #%d', + '%s opened the task #%d' => '%s เปิดงานแล้ว #%d', '%s moved the task #%d to the column "%s"' => '%s ย้ายงานแล้ว #%d ไปที่คอลัมน์ "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s ย้ายงานแล้ว #%d ไปตำแหน่ง %d ในคอลัมน์ที่ "%s"', 'Activity' => 'กิจกรรม', 'Default values are "%s"' => 'ค่าเริ่มต้น "%s"', 'Default columns for new projects (Comma-separated)' => 'คอลัมน์เริ่มต้นสำหรับโปรเจคใหม่ (Comma-separated)', 'Task assignee change' => 'เปลี่ยนการกำหนดบุคคลของงาน', - '%s change the assignee of the task #%d to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน #%d เป็น %s', + '%s changed the assignee of the task #%d to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน #%d เป็น %s', '%s changed the assignee of the task %s to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน %s เป็น %s', 'New password for the user "%s"' => 'รหัสผ่านใหม่สำหรับผู้ใช้ "%s"', 'Choose an event' => 'เลือกเหตุการณ์', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'เพิ่มอัตราค่าเงินเรียบร้อย', 'Unable to add this currency rate.' => 'ไม่สามารถเพิ่มค่าเงินนี้', // 'Webhook URL' => '', - '%s remove the assignee of the task %s' => '%s เอาผู้รับผิดชอบออกจากงาน %s', + '%s removed the assignee of the task %s' => '%s เอาผู้รับผิดชอบออกจากงาน %s', 'Enable Gravatar images' => 'สามารถใช้งานภาพ Gravatar', 'Information' => 'ข้อมูลสารสนเทศ', // 'Check two factor authentication code' => '', diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index 4f4c84cd9..5a1b84b8c 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s kullanıcısı #%d nolu görevi güncelledi', '%s created the task #%d' => '%s kullanıcısı #%d nolu görevi oluşturdu', '%s closed the task #%d' => '%s kullanıcısı #%d nolu görevi kapattı', - '%s open the task #%d' => '%s kullanıcısı #%d nolu görevi açtı', + '%s opened the task #%d' => '%s kullanıcısı #%d nolu görevi açtı', '%s moved the task #%d to the column "%s"' => '%s kullanıcısı #%d nolu görevi "%s" sütununa taşıdı', '%s moved the task #%d to the position %d in the column "%s"' => '%s kullanıcısı #%d nolu görevi %d pozisyonu "%s" sütununa taşıdı', 'Activity' => 'Aktivite', 'Default values are "%s"' => 'Varsayılan değerler "%s"', 'Default columns for new projects (Comma-separated)' => 'Yeni projeler için varsayılan sütunlar (virgül ile ayrılmış)', 'Task assignee change' => 'Göreve atanan kullanıcı değişikliği', - '%s change the assignee of the task #%d to %s' => '%s kullanıcısı #%d nolu görevin sorumlusunu %s olarak değiştirdi', + '%s changed the assignee of the task #%d to %s' => '%s kullanıcısı #%d nolu görevin sorumlusunu %s olarak değiştirdi', '%s changed the assignee of the task %s to %s' => '%s kullanıcısı %s görevinin sorumlusunu %s olarak değiştirdi', 'New password for the user "%s"' => '"%s" kullanıcısı için yeni şifre', 'Choose an event' => 'Bir durum seçin', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Kur başarıyla eklendi', 'Unable to add this currency rate.' => 'Bu kur eklenemedi', // 'Webhook URL' => '', - '%s remove the assignee of the task %s' => '%s, %s görevinin atanan bilgisini kaldırdı', + '%s removed the assignee of the task %s' => '%s, %s görevinin atanan bilgisini kaldırdı', 'Enable Gravatar images' => 'Gravatar resimlerini kullanıma aç', 'Information' => 'Bilgi', 'Check two factor authentication code' => 'İki kademeli doğrulama kodunu kontrol et', diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index 01eaff17d..f173fdff3 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s 更新了任务 #%d', '%s created the task #%d' => '%s 创建了任务 #%d', '%s closed the task #%d' => '%s 关闭了任务 #%d', - '%s open the task #%d' => '%s 开启了任务 #%d', + '%s opened the task #%d' => '%s 开启了任务 #%d', '%s moved the task #%d to the column "%s"' => '%s 将任务 #%d 移动到栏目 "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s将任务#%d移动到"%s"的第 %d 列', 'Activity' => '动态', 'Default values are "%s"' => '默认值为 "%s"', 'Default columns for new projects (Comma-separated)' => '新建项目的默认栏目(用逗号分开)', 'Task assignee change' => '任务分配变更', - '%s change the assignee of the task #%d to %s' => '%s 将任务 #%d 分配给了 %s', + '%s changed the assignee of the task #%d to %s' => '%s 将任务 #%d 分配给了 %s', '%s changed the assignee of the task %s to %s' => '%s 将任务 %s 分配给 %s', 'New password for the user "%s"' => '用户"%s"的新密码', 'Choose an event' => '选择一个事件', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => '成功添加汇率。', 'Unable to add this currency rate.' => '无法添加此汇率', 'Webhook URL' => '网络钩子 URL', - '%s remove the assignee of the task %s' => '%s删除了任务%s的负责人', + '%s removed the assignee of the task %s' => '%s删除了任务%s的负责人', 'Enable Gravatar images' => '启用 Gravatar 图像', 'Information' => '信息', 'Check two factor authentication code' => '检查双重认证码', diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index 39c1f5813..803d4f184 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -3,10 +3,15 @@ namespace Kanboard\Model; use Kanboard\Core\Base; +use Kanboard\EventBuilder\CommentEventBuilder; +use Kanboard\EventBuilder\EventIteratorBuilder; +use Kanboard\EventBuilder\SubtaskEventBuilder; +use Kanboard\EventBuilder\TaskEventBuilder; +use Kanboard\EventBuilder\TaskFileEventBuilder; use Kanboard\EventBuilder\TaskLinkEventBuilder; /** - * Notification + * Notification Model * * @package Kanboard\Model * @author Frederic Guillot @@ -17,150 +22,79 @@ class NotificationModel extends Base * Get the event title with author * * @access public - * @param string $event_author - * @param string $event_name - * @param array $event_data + * @param string $eventAuthor + * @param string $eventName + * @param array $eventData * @return string */ - public function getTitleWithAuthor($event_author, $event_name, array $event_data) + public function getTitleWithAuthor($eventAuthor, $eventName, array $eventData) { - switch ($event_name) { - case TaskModel::EVENT_ASSIGNEE_CHANGE: - $assignee = $event_data['task']['assignee_name'] ?: $event_data['task']['assignee_username']; + foreach ($this->getIteratorBuilder() as $builder) { + $title = $builder->buildTitleWithAuthor($eventAuthor, $eventName, $eventData); - if (! empty($assignee)) { - return e('%s change the assignee of the task #%d to %s', $event_author, $event_data['task']['id'], $assignee); - } - - return e('%s remove the assignee of the task %s', $event_author, e('#%d', $event_data['task']['id'])); - case TaskModel::EVENT_UPDATE: - return e('%s updated the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_CREATE: - return e('%s created the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_CLOSE: - return e('%s closed the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_OPEN: - return e('%s open the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_MOVE_COLUMN: - return e( - '%s moved the task #%d to the column "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['column_title'] - ); - case TaskModel::EVENT_MOVE_POSITION: - return e( - '%s moved the task #%d to the position %d in the column "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['position'], - $event_data['task']['column_title'] - ); - case TaskModel::EVENT_MOVE_SWIMLANE: - if ($event_data['task']['swimlane_id'] == 0) { - return e('%s moved the task #%d to the first swimlane', $event_author, $event_data['task']['id']); - } - - return e( - '%s moved the task #%d to the swimlane "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['swimlane_name'] - ); - case SubtaskModel::EVENT_UPDATE: - return e('%s updated a subtask for the task #%d', $event_author, $event_data['task']['id']); - case SubtaskModel::EVENT_CREATE: - return e('%s created a subtask for the task #%d', $event_author, $event_data['task']['id']); - case SubtaskModel::EVENT_DELETE: - return e('%s removed a subtask for the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_UPDATE: - return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_CREATE: - return e('%s commented on the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_DELETE: - return e('%s removed a comment on the task #%d', $event_author, $event_data['task']['id']); - case TaskFileModel::EVENT_CREATE: - return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_USER_MENTION: - return e('%s mentioned you in the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_USER_MENTION: - return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']); - default: - return TaskLinkEventBuilder::getInstance($this->container) - ->buildTitleWithAuthor($event_author, $event_name, $event_data) ?: - e('Notification'); + if ($title !== '') { + return $title; + } } + + return e('Notification'); } /** * Get the event title without author * * @access public - * @param string $event_name - * @param array $event_data + * @param string $eventName + * @param array $eventData * @return string */ - public function getTitleWithoutAuthor($event_name, array $event_data) + public function getTitleWithoutAuthor($eventName, array $eventData) { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - return e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']); - case CommentModel::EVENT_CREATE: - return e('New comment on task #%d', $event_data['comment']['task_id']); - case CommentModel::EVENT_UPDATE: - return e('Comment updated on task #%d', $event_data['comment']['task_id']); - case CommentModel::EVENT_DELETE: - return e('Comment removed on task #%d', $event_data['comment']['task_id']); - case SubtaskModel::EVENT_CREATE: - return e('New subtask on task #%d', $event_data['subtask']['task_id']); - case SubtaskModel::EVENT_UPDATE: - return e('Subtask updated on task #%d', $event_data['subtask']['task_id']); - case SubtaskModel::EVENT_DELETE: - return e('Subtask removed on task #%d', $event_data['subtask']['task_id']); - case TaskModel::EVENT_CREATE: - return e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']); - case TaskModel::EVENT_UPDATE: - return e('Task updated #%d', $event_data['task']['id']); - case TaskModel::EVENT_CLOSE: - return e('Task #%d closed', $event_data['task']['id']); - case TaskModel::EVENT_OPEN: - return e('Task #%d opened', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_COLUMN: - return e('Column changed for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_POSITION: - return e('New position for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_SWIMLANE: - return e('Swimlane changed for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_ASSIGNEE_CHANGE: - return e('Assignee changed on task #%d', $event_data['task']['id']); - case TaskModel::EVENT_OVERDUE: - $nb = count($event_data['tasks']); - return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']); - case TaskModel::EVENT_USER_MENTION: - return e('You were mentioned in the task #%d', $event_data['task']['id']); - case CommentModel::EVENT_USER_MENTION: - return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']); - default: - return TaskLinkEventBuilder::getInstance($this->container) - ->buildTitleWithoutAuthor($event_name, $event_data) ?: - e('Notification'); + foreach ($this->getIteratorBuilder() as $builder) { + $title = $builder->buildTitleWithoutAuthor($eventName, $eventData); + + if ($title !== '') { + return $title; + } } + + return e('Notification'); } /** * Get task id from event * * @access public - * @param string $event_name - * @param array $event_data + * @param string $eventName + * @param array $eventData * @return integer */ - public function getTaskIdFromEvent($event_name, array $event_data) + public function getTaskIdFromEvent($eventName, array $eventData) { - if ($event_name === TaskModel::EVENT_OVERDUE) { - return $event_data['tasks'][0]['id']; + if ($eventName === TaskModel::EVENT_OVERDUE) { + return $eventData['tasks'][0]['id']; } - - return isset($event_data['task']['id']) ? $event_data['task']['id'] : 0; + + return isset($eventData['task']['id']) ? $eventData['task']['id'] : 0; + } + + /** + * Get iterator builder + * + * @access protected + * @return EventIteratorBuilder + */ + protected function getIteratorBuilder() + { + $iterator = new EventIteratorBuilder(); + $iterator + ->withBuilder(TaskEventBuilder::getInstance($this->container)) + ->withBuilder(CommentEventBuilder::getInstance($this->container)) + ->withBuilder(SubtaskEventBuilder::getInstance($this->container)) + ->withBuilder(TaskFileEventBuilder::getInstance($this->container)) + ->withBuilder(TaskLinkEventBuilder::getInstance($this->container)) + ; + + return $iterator; } } diff --git a/app/Template/event/task_assignee_change.php b/app/Template/event/task_assignee_change.php index 7c9622233..7539cd0bc 100644 --- a/app/Template/event/task_assignee_change.php +++ b/app/Template/event/task_assignee_change.php @@ -8,7 +8,7 @@ $this->text->e($assignee) ) ?> - text->e($author), $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))) ?> + text->e($author), $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))) ?> dt->datetime($date_creation) ?>

diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php index b9d7e9d98..1576f81b3 100644 --- a/tests/units/Action/TaskAssignCategoryLinkTest.php +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -33,7 +33,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -62,7 +62,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -91,7 +91,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); diff --git a/tests/units/Action/TaskAssignColorLinkTest.php b/tests/units/Action/TaskAssignColorLinkTest.php index 27364bc9e..77a6c90e8 100644 --- a/tests/units/Action/TaskAssignColorLinkTest.php +++ b/tests/units/Action/TaskAssignColorLinkTest.php @@ -30,7 +30,7 @@ class TaskAssignColorLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -57,7 +57,7 @@ class TaskAssignColorLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); diff --git a/tests/units/EventBuilder/CommentEventBuilderTest.php b/tests/units/EventBuilder/CommentEventBuilderTest.php index a490799e7..2f6a90b5a 100644 --- a/tests/units/EventBuilder/CommentEventBuilderTest.php +++ b/tests/units/EventBuilder/CommentEventBuilderTest.php @@ -13,7 +13,7 @@ class CommentEventBuilderTest extends Base { $commentEventBuilder = new CommentEventBuilder($this->container); $commentEventBuilder->withCommentId(42); - $this->assertNull($commentEventBuilder->build()); + $this->assertNull($commentEventBuilder->buildEvent()); } public function testBuild() @@ -28,7 +28,7 @@ class CommentEventBuilderTest extends Base $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla', 'user_id' => 1))); $commentEventBuilder->withCommentId(1); - $event = $commentEventBuilder->build(); + $event = $commentEventBuilder->buildEvent(); $this->assertInstanceOf('Kanboard\Event\CommentEvent', $event); $this->assertNotEmpty($event['comment']); diff --git a/tests/units/EventBuilder/ProjectFileEventBuilderTest.php b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php index bfe227194..8f5eb87ec 100644 --- a/tests/units/EventBuilder/ProjectFileEventBuilderTest.php +++ b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php @@ -12,7 +12,7 @@ class ProjectFileEventBuilderTest extends Base { $projectFileEventBuilder = new ProjectFileEventBuilder($this->container); $projectFileEventBuilder->withFileId(42); - $this->assertNull($projectFileEventBuilder->build()); + $this->assertNull($projectFileEventBuilder->buildEvent()); } public function testBuild() @@ -24,7 +24,7 @@ class ProjectFileEventBuilderTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $projectFileModel->create(1, 'Test', '/tmp/test', 123)); - $event = $projectFileEventBuilder->withFileId(1)->build(); + $event = $projectFileEventBuilder->withFileId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\ProjectFileEvent', $event); $this->assertNotEmpty($event['file']); diff --git a/tests/units/EventBuilder/SubtaskEventBuilderTest.php b/tests/units/EventBuilder/SubtaskEventBuilderTest.php index 062bdfb4d..fe425cb8c 100644 --- a/tests/units/EventBuilder/SubtaskEventBuilderTest.php +++ b/tests/units/EventBuilder/SubtaskEventBuilderTest.php @@ -13,7 +13,7 @@ class SubtaskEventBuilderTest extends Base { $subtaskEventBuilder = new SubtaskEventBuilder($this->container); $subtaskEventBuilder->withSubtaskId(42); - $this->assertNull($subtaskEventBuilder->build()); + $this->assertNull($subtaskEventBuilder->buildEvent()); } public function testBuildWithoutChanges() @@ -27,7 +27,7 @@ class SubtaskEventBuilderTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'test'))); - $event = $subtaskEventBuilder->withSubtaskId(1)->build(); + $event = $subtaskEventBuilder->withSubtaskId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); $this->assertNotEmpty($event['subtask']); @@ -49,7 +49,7 @@ class SubtaskEventBuilderTest extends Base $event = $subtaskEventBuilder ->withSubtaskId(1) ->withValues(array('title' => 'new title', 'user_id' => 1)) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); $this->assertNotEmpty($event['subtask']); diff --git a/tests/units/EventBuilder/TaskEventBuilderTest.php b/tests/units/EventBuilder/TaskEventBuilderTest.php index e6334fe2c..c89dcd855 100644 --- a/tests/units/EventBuilder/TaskEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskEventBuilderTest.php @@ -12,7 +12,7 @@ class TaskEventBuilderTest extends Base { $taskEventBuilder = new TaskEventBuilder($this->container); $taskEventBuilder->withTaskId(42); - $this->assertNull($taskEventBuilder->build()); + $this->assertNull($taskEventBuilder->buildEvent()); } public function testBuildWithTask() @@ -28,7 +28,7 @@ class TaskEventBuilderTest extends Base ->withTaskId(1) ->withTask(array('title' => 'before')) ->withChanges(array('title' => 'after')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -45,7 +45,7 @@ class TaskEventBuilderTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); - $event = $taskEventBuilder->withTaskId(1)->build(); + $event = $taskEventBuilder->withTaskId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -65,7 +65,7 @@ class TaskEventBuilderTest extends Base $event = $taskEventBuilder ->withTaskId(1) ->withChanges(array('title' => 'new title')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -86,7 +86,7 @@ class TaskEventBuilderTest extends Base ->withTaskId(1) ->withChanges(array('title' => 'new title', 'project_id' => 1)) ->withValues(array('key' => 'value')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); diff --git a/tests/units/EventBuilder/TaskFileEventBuilderTest.php b/tests/units/EventBuilder/TaskFileEventBuilderTest.php index c253b9134..c90e18d31 100644 --- a/tests/units/EventBuilder/TaskFileEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskFileEventBuilderTest.php @@ -13,7 +13,7 @@ class TaskFileEventBuilderTest extends Base { $taskFileEventBuilder = new TaskFileEventBuilder($this->container); $taskFileEventBuilder->withFileId(42); - $this->assertNull($taskFileEventBuilder->build()); + $this->assertNull($taskFileEventBuilder->buildEvent()); } public function testBuild() @@ -27,7 +27,7 @@ class TaskFileEventBuilderTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $taskFileModel->create(1, 'Test', '/tmp/test', 123)); - $event = $taskFileEventBuilder->withFileId(1)->build(); + $event = $taskFileEventBuilder->withFileId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskFileEvent', $event); $this->assertNotEmpty($event['file']); diff --git a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php index 7364d6512..185081462 100644 --- a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php @@ -13,7 +13,7 @@ class TaskLinkEventBuilderTest extends Base { $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); $taskLinkEventBuilder->withTaskLinkId(42); - $this->assertNull($taskLinkEventBuilder->build()); + $this->assertNull($taskLinkEventBuilder->buildEvent()); } public function testBuild() @@ -28,7 +28,7 @@ class TaskLinkEventBuilderTest extends Base $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $event = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + $event = $taskLinkEventBuilder->withTaskLinkId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskLinkEvent', $event); $this->assertNotEmpty($event['task_link']); @@ -47,7 +47,7 @@ class TaskLinkEventBuilderTest extends Base $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->buildEvent(); $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); $this->assertEquals('Foobar set a new internal link for the task #1', $title); From b179802a85b262529aaa46ed9cf072a570be25ce Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:58:40 -0400 Subject: [PATCH 106/139] Sync locales --- app/Locale/bs_BA/translations.php | 6 ++++++ app/Locale/cs_CZ/translations.php | 6 ++++++ app/Locale/da_DK/translations.php | 6 ++++++ app/Locale/de_DE/translations.php | 6 ++++++ app/Locale/el_GR/translations.php | 6 ++++++ app/Locale/es_ES/translations.php | 6 ++++++ app/Locale/fi_FI/translations.php | 6 ++++++ app/Locale/fr_FR/translations.php | 6 ++++++ app/Locale/hu_HU/translations.php | 6 ++++++ app/Locale/id_ID/translations.php | 6 ++++++ app/Locale/it_IT/translations.php | 6 ++++++ app/Locale/ja_JP/translations.php | 6 ++++++ app/Locale/ko_KR/translations.php | 6 ++++++ app/Locale/my_MY/translations.php | 6 ++++++ app/Locale/nb_NO/translations.php | 6 ++++++ app/Locale/nl_NL/translations.php | 6 ++++++ app/Locale/pl_PL/translations.php | 6 ++++++ app/Locale/pt_BR/translations.php | 6 ++++++ app/Locale/pt_PT/translations.php | 6 ++++++ app/Locale/ru_RU/translations.php | 6 ++++++ app/Locale/sr_Latn_RS/translations.php | 6 ++++++ app/Locale/sv_SE/translations.php | 6 ++++++ app/Locale/th_TH/translations.php | 6 ++++++ app/Locale/tr_TR/translations.php | 6 ++++++ app/Locale/zh_CN/translations.php | 6 ++++++ 25 files changed, 150 insertions(+) diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php index f1529e027..908112dfd 100644 --- a/app/Locale/bs_BA/translations.php +++ b/app/Locale/bs_BA/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index c7e6e536d..3606e6d22 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index 6cecfaeca..d1e86739f 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index d25e7e8a8..51998dc42 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php index b02207d51..e2ea0a699 100644 --- a/app/Locale/el_GR/translations.php +++ b/app/Locale/el_GR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index fa59ca07e..088a4fbba 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 200a9cded..316c20896 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 9f6cf971d..19ce49b4e 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -1227,4 +1227,10 @@ return array( '%s removed a subtask for the task %s' => '%s a supprimé une sous-tâche de la tâche %s', 'Comment removed' => 'Commentaire supprimé', 'Subtask removed' => 'Sous-tâche supprimée', + '%s set a new internal link for the task #%d' => '%s a défini un nouveau lien interne pour la tâche n°%d', + '%s removed an internal link for the task #%d' => '%s a supprimé un lien interne pour la tâche n°%d', + 'A new internal link for the task #%d have been defined' => 'Un nouveau lien interne pour la tâche n°%d a été défini', + 'Internal link removed for the task #%d' => 'Lien interne supprimé pour la tâche n°%d', + '%s set a new internal link for the task %s' => '%s a défini un nouveau lien interne pour la tâche %s', + '%s removed an internal link for the task %s' => '%s a supprimé un lien interne pour la tâche %s', ); diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index 781a04234..a0365940f 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 26e091ceb..4cdfd129b 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index aadbfe5b0..334faa467 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index 03fa55ed7..fea7283d1 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ko_KR/translations.php b/app/Locale/ko_KR/translations.php index bf140d940..7ba0f456c 100644 --- a/app/Locale/ko_KR/translations.php +++ b/app/Locale/ko_KR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php index cf4f399c0..68bd12bf8 100644 --- a/app/Locale/my_MY/translations.php +++ b/app/Locale/my_MY/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index ce69deb9e..c81e073c5 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index d5ba70367..d0b90ef81 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index f2570d7cc..7c28190a2 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 46749043c..3f5a6de55 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 4fd070d12..4e3e01518 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -1226,4 +1226,10 @@ return array( '%s removed a subtask for the task %s' => '%s removeu uma sub-tarefa da tarefa %s', 'Comment removed' => 'Comentário removido', 'Subtask removed' => 'Sub-tarefa removida', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index 92fba163b..728a79f25 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 6a4bfc687..25779a15a 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index 7eb46a980..f4206bb13 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 659797532..ec3997322 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index 5a1b84b8c..aa59f7e72 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index f173fdff3..99d07561d 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); From d4606f69f6b0517f45f19d511a46004ae5dc7a5b Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 15:15:11 -0400 Subject: [PATCH 107/139] Minor cleanup --- .scrutinizer.yml | 11 ----------- app/Core/Base.php | 7 +++---- app/EventBuilder/EventIteratorBuilder.php | 4 ++++ config.default.php | 8 ++++---- 4 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 .scrutinizer.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 25ef09c49..000000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,11 +0,0 @@ -filter: - paths: - - app/* - excluded_paths: - - app/Schema/* - - app/Template/* - - app/Locale/* - - app/Library/* - - app/constants.php - - app/common.php - - app/check_setup.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 20a2d391b..6931d93a4 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -185,10 +185,10 @@ abstract class Base } /** - * Load automatically models + * Load automatically dependencies * * @access public - * @param string $name Model name + * @param string $name Class name * @return mixed */ public function __get($name) @@ -206,7 +206,6 @@ abstract class Base */ public static function getInstance(Container $container) { - $self = new static($container); - return $self; + return new static($container); } } diff --git a/app/EventBuilder/EventIteratorBuilder.php b/app/EventBuilder/EventIteratorBuilder.php index afa146b67..ba8217532 100644 --- a/app/EventBuilder/EventIteratorBuilder.php +++ b/app/EventBuilder/EventIteratorBuilder.php @@ -15,6 +15,10 @@ class EventIteratorBuilder implements Iterator { private $builders = array(); /** + * Set builder + * + * @access public + * @param BaseEventBuilder $builder * @return $this */ public function withBuilder(BaseEventBuilder $builder) diff --git a/config.default.php b/config.default.php index a9fd7d992..d0e93a8e1 100644 --- a/config.default.php +++ b/config.default.php @@ -11,16 +11,16 @@ define('DEBUG', false); define('LOG_DRIVER', ''); // Log filename if the log driver is "file" -define('LOG_FILE', __DIR__.DIRECTORY_SEPARATOR.'data'.DIRECTORY_SEPARATOR.'debug.log'); +define('LOG_FILE', DATA_DIR.DIRECTORY_SEPARATOR.'debug.log'); // Plugins directory -define('PLUGINS_DIR', 'plugins'); +define('PLUGINS_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'plugins'); // Folder for uploaded files -define('FILES_DIR', 'data'.DIRECTORY_SEPARATOR.'files'); +define('FILES_DIR', DATA_DIR.DIRECTORY_SEPARATOR.'files'); // E-mail address for the "From" header (notifications) -define('MAIL_FROM', 'notifications@kanboard.local'); +define('MAIL_FROM', 'replace-me@kanboard.local'); // Mail transport available: "smtp", "sendmail", "mail" (PHP mail function), "postmark", "mailgun", "sendgrid" define('MAIL_TRANSPORT', 'mail'); From e0d330dda8dea91936d5b76e212603d106e45386 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 15:19:35 -0400 Subject: [PATCH 108/139] Move Github templates to a folder --- issue_template.md => .github/issue_template.md | 0 pull_request_template.md => .github/pull_request_template.md | 0 CONTRIBUTING | 1 + 3 files changed, 1 insertion(+) rename issue_template.md => .github/issue_template.md (100%) rename pull_request_template.md => .github/pull_request_template.md (100%) create mode 100644 CONTRIBUTING diff --git a/issue_template.md b/.github/issue_template.md similarity index 100% rename from issue_template.md rename to .github/issue_template.md diff --git a/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from pull_request_template.md rename to .github/pull_request_template.md diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 000000000..68a20f514 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1 @@ +Read https://kanboard.net/documentation/contributing From 2a42e0e1aae35a9bb7abf054155b516ffab701d4 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:10:05 -0400 Subject: [PATCH 109/139] Added a new automatic action to set due date --- ChangeLog | 5 +- app/Action/TaskAssignDueDateOnCreation.php | 96 +++++++++++++++++++ app/ServiceProvider/ActionProvider.php | 2 + .../TaskAssignDueDateOnCreationTest.php | 37 +++++++ .../units/Action/TaskUpdateStartDateTest.php | 3 +- 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 app/Action/TaskAssignDueDateOnCreation.php create mode 100644 tests/units/Action/TaskAssignDueDateOnCreationTest.php diff --git a/ChangeLog b/ChangeLog index ee57c86c5..01ad5fbdb 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,7 +3,9 @@ Version 1.0.32 (unreleased) New features: -* New automated action to close tasks without activity in a specific column +* New automated actions: + - Close tasks without activity in a specific column + - Set due date automatically * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority @@ -11,6 +13,7 @@ New features: Improvements: +* Internal events management refactoring * Handle header X-Real-IP to get IP address * Display project name for task auto-complete fields * Make search attributes not case sensitive diff --git a/app/Action/TaskAssignDueDateOnCreation.php b/app/Action/TaskAssignDueDateOnCreation.php new file mode 100644 index 000000000..79ff765c0 --- /dev/null +++ b/app/Action/TaskAssignDueDateOnCreation.php @@ -0,0 +1,96 @@ + t('Duration in days') + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array( + 'task_id', + 'task' => array( + 'project_id', + ), + ); + } + + /** + * Execute the action (set the task color) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $values = array( + 'id' => $data['task_id'], + 'date_due' => strtotime('+'.$this->getParam('duration').'days'), + ); + + return $this->taskModificationModel->update($values, false); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return true; + } +} diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index 9383be12b..c76555fa8 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -3,6 +3,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; +use Kanboard\Action\TaskAssignDueDateOnCreation; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -80,6 +81,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); + $container['actionManager']->register(new TaskAssignDueDateOnCreation($container)); return $container; } diff --git a/tests/units/Action/TaskAssignDueDateOnCreationTest.php b/tests/units/Action/TaskAssignDueDateOnCreationTest.php new file mode 100644 index 000000000..26c0584ec --- /dev/null +++ b/tests/units/Action/TaskAssignDueDateOnCreationTest.php @@ -0,0 +1,37 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskAssignDueDateOnCreation($this->container); + $action->setProjectId(1); + $action->setParam('duration', 4); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_CREATE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(date('Y-m-d', strtotime('+4days')), date('Y-m-d', $task['date_due'])); + } +} diff --git a/tests/units/Action/TaskUpdateStartDateTest.php b/tests/units/Action/TaskUpdateStartDateTest.php index 8d609b3e9..05fac1006 100644 --- a/tests/units/Action/TaskUpdateStartDateTest.php +++ b/tests/units/Action/TaskUpdateStartDateTest.php @@ -2,7 +2,6 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; @@ -12,7 +11,7 @@ use Kanboard\Action\TaskUpdateStartDate; class TaskUpdateStartDateTest extends Base { - public function testClose() + public function testAction() { $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); From 9b2a32af78ef8fb5424398dc57e3c3f906026272 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:33:31 -0400 Subject: [PATCH 110/139] Add new automatic action to move a task to another column when closed --- ChangeLog | 1 + app/Action/Base.php | 2 +- app/Action/CommentCreation.php | 2 +- app/Action/CommentCreationMoveTaskColumn.php | 2 +- app/Action/TaskAssignCategoryColor.php | 2 +- app/Action/TaskAssignCategoryLabel.php | 2 +- app/Action/TaskAssignCategoryLink.php | 2 +- app/Action/TaskAssignColorCategory.php | 2 +- app/Action/TaskAssignColorColumn.php | 2 +- app/Action/TaskAssignColorLink.php | 2 +- app/Action/TaskAssignColorPriority.php | 2 +- app/Action/TaskAssignColorUser.php | 2 +- app/Action/TaskAssignCurrentUser.php | 2 +- app/Action/TaskAssignCurrentUserColumn.php | 2 +- app/Action/TaskAssignDueDateOnCreation.php | 2 +- app/Action/TaskAssignSpecificUser.php | 2 +- app/Action/TaskAssignUser.php | 2 +- app/Action/TaskClose.php | 2 +- app/Action/TaskCloseColumn.php | 2 +- app/Action/TaskCloseNoActivity.php | 2 +- app/Action/TaskCloseNoActivityColumn.php | 2 +- app/Action/TaskCreation.php | 2 +- app/Action/TaskDuplicateAnotherProject.php | 2 +- app/Action/TaskEmail.php | 2 +- app/Action/TaskEmailNoActivity.php | 2 +- app/Action/TaskMoveAnotherProject.php | 2 +- app/Action/TaskMoveColumnAssigned.php | 2 +- app/Action/TaskMoveColumnCategoryChange.php | 2 +- app/Action/TaskMoveColumnClosed.php | 102 ++++++++++++++++++ app/Action/TaskMoveColumnUnAssigned.php | 2 +- app/Action/TaskOpen.php | 2 +- app/Action/TaskUpdateStartDate.php | 2 +- app/Model/TaskPositionModel.php | 19 ++-- app/ServiceProvider/ActionProvider.php | 2 + .../units/Action/TaskMoveColumnClosedTest.php | 91 ++++++++++++++++ 35 files changed, 236 insertions(+), 39 deletions(-) create mode 100644 app/Action/TaskMoveColumnClosed.php create mode 100644 tests/units/Action/TaskMoveColumnClosedTest.php diff --git a/ChangeLog b/ChangeLog index 01ad5fbdb..c9aebc486 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,7 @@ New features: * New automated actions: - Close tasks without activity in a specific column - Set due date automatically + - Move a task to another column when closed * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority diff --git a/app/Action/Base.php b/app/Action/Base.php index e0ed8bde8..9a502a08a 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -7,7 +7,7 @@ use Kanboard\Event\GenericEvent; /** * Base class for automatic actions * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ abstract class Base extends \Kanboard\Core\Base diff --git a/app/Action/CommentCreation.php b/app/Action/CommentCreation.php index 60ca24f70..301d2cf90 100644 --- a/app/Action/CommentCreation.php +++ b/app/Action/CommentCreation.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Create automatically a comment from a webhook * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class CommentCreation extends Base diff --git a/app/Action/CommentCreationMoveTaskColumn.php b/app/Action/CommentCreationMoveTaskColumn.php index 8ab792ad3..d5bdd807e 100644 --- a/app/Action/CommentCreationMoveTaskColumn.php +++ b/app/Action/CommentCreationMoveTaskColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Add a comment of the triggering event to the task description. * - * @package action + * @package Kanboard\Action * @author Oren Ben-Kiki */ class CommentCreationMoveTaskColumn extends Base diff --git a/app/Action/TaskAssignCategoryColor.php b/app/Action/TaskAssignCategoryColor.php index 2df90b2cf..9228e1ff2 100644 --- a/app/Action/TaskAssignCategoryColor.php +++ b/app/Action/TaskAssignCategoryColor.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set a category automatically according to the color * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCategoryColor extends Base diff --git a/app/Action/TaskAssignCategoryLabel.php b/app/Action/TaskAssignCategoryLabel.php index 482990101..c390414eb 100644 --- a/app/Action/TaskAssignCategoryLabel.php +++ b/app/Action/TaskAssignCategoryLabel.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Set a category automatically according to a label * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCategoryLabel extends Base diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php index d4a4c0ec8..6c4b6c962 100644 --- a/app/Action/TaskAssignCategoryLink.php +++ b/app/Action/TaskAssignCategoryLink.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskLinkModel; /** * Set a category automatically according to a task link * - * @package action + * @package Kanboard\Action * @author Olivier Maridat * @author Frederic Guillot */ diff --git a/app/Action/TaskAssignColorCategory.php b/app/Action/TaskAssignColorCategory.php index 91860be4f..a136ffd2d 100644 --- a/app/Action/TaskAssignColorCategory.php +++ b/app/Action/TaskAssignColorCategory.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a specific category * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorCategory extends Base diff --git a/app/Action/TaskAssignColorColumn.php b/app/Action/TaskAssignColorColumn.php index 6c674b1ff..da6e3aed3 100644 --- a/app/Action/TaskAssignColorColumn.php +++ b/app/Action/TaskAssignColorColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorColumn extends Base diff --git a/app/Action/TaskAssignColorLink.php b/app/Action/TaskAssignColorLink.php index 9759f6224..19c37afef 100644 --- a/app/Action/TaskAssignColorLink.php +++ b/app/Action/TaskAssignColorLink.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskLinkModel; /** * Assign a color to a specific task link * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorLink extends Base diff --git a/app/Action/TaskAssignColorPriority.php b/app/Action/TaskAssignColorPriority.php index 57000ba88..37f7ffedf 100644 --- a/app/Action/TaskAssignColorPriority.php +++ b/app/Action/TaskAssignColorPriority.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a priority * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorPriority extends Base diff --git a/app/Action/TaskAssignColorUser.php b/app/Action/TaskAssignColorUser.php index 385db7939..468d0198a 100644 --- a/app/Action/TaskAssignColorUser.php +++ b/app/Action/TaskAssignColorUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a specific user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorUser extends Base diff --git a/app/Action/TaskAssignCurrentUser.php b/app/Action/TaskAssignCurrentUser.php index 997aa98f2..dee5e7db4 100644 --- a/app/Action/TaskAssignCurrentUser.php +++ b/app/Action/TaskAssignCurrentUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to the logged user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCurrentUser extends Base diff --git a/app/Action/TaskAssignCurrentUserColumn.php b/app/Action/TaskAssignCurrentUserColumn.php index e4eade332..60ada7ef8 100644 --- a/app/Action/TaskAssignCurrentUserColumn.php +++ b/app/Action/TaskAssignCurrentUserColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to the logged user on column change * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCurrentUserColumn extends Base diff --git a/app/Action/TaskAssignDueDateOnCreation.php b/app/Action/TaskAssignDueDateOnCreation.php index 79ff765c0..5c6e2b616 100644 --- a/app/Action/TaskAssignDueDateOnCreation.php +++ b/app/Action/TaskAssignDueDateOnCreation.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set the due date of task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignDueDateOnCreation extends Base diff --git a/app/Action/TaskAssignSpecificUser.php b/app/Action/TaskAssignSpecificUser.php index 2c7dcacd1..daf9e1dfb 100644 --- a/app/Action/TaskAssignSpecificUser.php +++ b/app/Action/TaskAssignSpecificUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to a specific user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignSpecificUser extends Base diff --git a/app/Action/TaskAssignUser.php b/app/Action/TaskAssignUser.php index 9ea22986d..8727b672d 100644 --- a/app/Action/TaskAssignUser.php +++ b/app/Action/TaskAssignUser.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Assign a task to someone * - * @package action + * @package Kanboard\Actionv * @author Frederic Guillot */ class TaskAssignUser extends Base diff --git a/app/Action/TaskClose.php b/app/Action/TaskClose.php index 91e8cf43a..e476e9ba5 100644 --- a/app/Action/TaskClose.php +++ b/app/Action/TaskClose.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Close automatically a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskClose extends Base diff --git a/app/Action/TaskCloseColumn.php b/app/Action/TaskCloseColumn.php index 4f1ffc92c..523996f44 100644 --- a/app/Action/TaskCloseColumn.php +++ b/app/Action/TaskCloseColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task in a specific column * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseColumn extends Base diff --git a/app/Action/TaskCloseNoActivity.php b/app/Action/TaskCloseNoActivity.php index 5a10510fe..ea724d8c9 100644 --- a/app/Action/TaskCloseNoActivity.php +++ b/app/Action/TaskCloseNoActivity.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task after when inactive * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseNoActivity extends Base diff --git a/app/Action/TaskCloseNoActivityColumn.php b/app/Action/TaskCloseNoActivityColumn.php index 7af0b7fc0..b2ee5224d 100644 --- a/app/Action/TaskCloseNoActivityColumn.php +++ b/app/Action/TaskCloseNoActivityColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task after inactive and in an defined column * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseNoActivityColumn extends Base diff --git a/app/Action/TaskCreation.php b/app/Action/TaskCreation.php index 0620afd3d..01d912280 100644 --- a/app/Action/TaskCreation.php +++ b/app/Action/TaskCreation.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Create automatically a task from a webhook * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCreation extends Base diff --git a/app/Action/TaskDuplicateAnotherProject.php b/app/Action/TaskDuplicateAnotherProject.php index d6d8d51fd..0ad7713cd 100644 --- a/app/Action/TaskDuplicateAnotherProject.php +++ b/app/Action/TaskDuplicateAnotherProject.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Duplicate a task to another project * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskDuplicateAnotherProject extends Base diff --git a/app/Action/TaskEmail.php b/app/Action/TaskEmail.php index 526e9aa8b..fdfe7987f 100644 --- a/app/Action/TaskEmail.php +++ b/app/Action/TaskEmail.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Email a task to someone * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskEmail extends Base diff --git a/app/Action/TaskEmailNoActivity.php b/app/Action/TaskEmailNoActivity.php index c60702fbe..cac4281ef 100644 --- a/app/Action/TaskEmailNoActivity.php +++ b/app/Action/TaskEmailNoActivity.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Email a task with no activity * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskEmailNoActivity extends Base diff --git a/app/Action/TaskMoveAnotherProject.php b/app/Action/TaskMoveAnotherProject.php index 148b6b0c2..0fa22b1b5 100644 --- a/app/Action/TaskMoveAnotherProject.php +++ b/app/Action/TaskMoveAnotherProject.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another project * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskMoveAnotherProject extends Base diff --git a/app/Action/TaskMoveColumnAssigned.php b/app/Action/TaskMoveColumnAssigned.php index 1c1f657ad..1cfe6743d 100644 --- a/app/Action/TaskMoveColumnAssigned.php +++ b/app/Action/TaskMoveColumnAssigned.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when an assignee is set * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnAssigned extends Base diff --git a/app/Action/TaskMoveColumnCategoryChange.php b/app/Action/TaskMoveColumnCategoryChange.php index 4c2b289a5..13d6ee4f8 100644 --- a/app/Action/TaskMoveColumnCategoryChange.php +++ b/app/Action/TaskMoveColumnCategoryChange.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when the category is changed * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnCategoryChange extends Base diff --git a/app/Action/TaskMoveColumnClosed.php b/app/Action/TaskMoveColumnClosed.php new file mode 100644 index 000000000..3f3e21247 --- /dev/null +++ b/app/Action/TaskMoveColumnClosed.php @@ -0,0 +1,102 @@ + t('Destination column'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array( + 'task_id', + 'task' => array( + 'project_id', + 'column_id', + 'swimlane_id', + 'is_active', + ) + ); + } + + /** + * Execute the action (move the task to another column) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + return $this->taskPositionModel->movePosition( + $data['task']['project_id'], + $data['task']['id'], + $this->getParam('dest_column_id'), + 1, + $data['task']['swimlane_id'], + false, + false + ); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return $data['task']['column_id'] != $this->getParam('dest_column_id') && $data['task']['is_active'] == 0; + } +} diff --git a/app/Action/TaskMoveColumnUnAssigned.php b/app/Action/TaskMoveColumnUnAssigned.php index 0e9a8a16e..ab63d6244 100644 --- a/app/Action/TaskMoveColumnUnAssigned.php +++ b/app/Action/TaskMoveColumnUnAssigned.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when an assignee is cleared * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnUnAssigned extends Base diff --git a/app/Action/TaskOpen.php b/app/Action/TaskOpen.php index 8e847b8e3..490178315 100644 --- a/app/Action/TaskOpen.php +++ b/app/Action/TaskOpen.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Open automatically a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskOpen extends Base diff --git a/app/Action/TaskUpdateStartDate.php b/app/Action/TaskUpdateStartDate.php index cc016da12..160f6ee5a 100644 --- a/app/Action/TaskUpdateStartDate.php +++ b/app/Action/TaskUpdateStartDate.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set the start date of task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskUpdateStartDate extends Base diff --git a/app/Model/TaskPositionModel.php b/app/Model/TaskPositionModel.php index d6d2a0af5..3d95a7633 100644 --- a/app/Model/TaskPositionModel.php +++ b/app/Model/TaskPositionModel.php @@ -16,15 +16,16 @@ class TaskPositionModel extends Base * Move a task to another column or to another position * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param integer $column_id Column id - * @param integer $position Position (must be >= 1) - * @param integer $swimlane_id Swimlane id - * @param boolean $fire_events Fire events - * @return boolean + * @param integer $project_id Project id + * @param integer $task_id Task id + * @param integer $column_id Column id + * @param integer $position Position (must be >= 1) + * @param integer $swimlane_id Swimlane id + * @param boolean $fire_events Fire events + * @param bool $onlyOpen Do not move closed tasks + * @return bool */ - public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true) + public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true, $onlyOpen = true) { if ($position < 1) { return false; @@ -32,7 +33,7 @@ class TaskPositionModel extends Base $task = $this->taskFinderModel->getById($task_id); - if ($task['is_active'] == TaskModel::STATUS_CLOSED) { + if ($onlyOpen && $task['is_active'] == TaskModel::STATUS_CLOSED) { return true; } diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index c76555fa8..cbc606795 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -4,6 +4,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; use Kanboard\Action\TaskAssignDueDateOnCreation; +use Kanboard\Action\TaskMoveColumnClosed; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -78,6 +79,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveAnotherProject($container)); $container['actionManager']->register(new TaskMoveColumnAssigned($container)); $container['actionManager']->register(new TaskMoveColumnCategoryChange($container)); + $container['actionManager']->register(new TaskMoveColumnClosed($container)); $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); diff --git a/tests/units/Action/TaskMoveColumnClosedTest.php b/tests/units/Action/TaskMoveColumnClosedTest.php new file mode 100644 index 000000000..318b995d1 --- /dev/null +++ b/tests/units/Action/TaskMoveColumnClosedTest.php @@ -0,0 +1,91 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(2, $task['column_id']); + } + + public function testWhenTaskIsOpen() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertFalse($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(1, $task['column_id']); + } + + public function testWhenTaskIsAlreadyInDestinationColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0, 'column_id' => 2))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertFalse($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(2, $task['column_id']); + } +} From ca45b5592b17d3675a22b7aca49ea49dd9dd57ea Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:59:00 -0400 Subject: [PATCH 111/139] Add new automatic action to move the task to another column when not moved --- ChangeLog | 1 + app/Action/TaskMoveColumnNotMovedPeriod.php | 104 ++++++++++++++++++ app/ServiceProvider/ActionProvider.php | 2 + .../TaskMoveColumnNotMovedPeriodTest.php | 50 +++++++++ 4 files changed, 157 insertions(+) create mode 100644 app/Action/TaskMoveColumnNotMovedPeriod.php create mode 100644 tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php diff --git a/ChangeLog b/ChangeLog index c9aebc486..1bc0eed37 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,7 @@ New features: - Close tasks without activity in a specific column - Set due date automatically - Move a task to another column when closed + - Move a task to another column when not moved during a given period * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority diff --git a/app/Action/TaskMoveColumnNotMovedPeriod.php b/app/Action/TaskMoveColumnNotMovedPeriod.php new file mode 100644 index 000000000..87e7e4051 --- /dev/null +++ b/app/Action/TaskMoveColumnNotMovedPeriod.php @@ -0,0 +1,104 @@ + t('Duration in days'), + 'src_column_id' => t('Source column'), + 'dest_column_id' => t('Destination column'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array('tasks'); + } + + /** + * Execute the action (close the task) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $results = array(); + $max = $this->getParam('duration') * 86400; + + foreach ($data['tasks'] as $task) { + $duration = time() - $task['date_moved']; + + if ($duration > $max && $task['column_id'] == $this->getParam('src_column_id')) { + $results[] = $this->taskPositionModel->movePosition( + $task['project_id'], + $task['id'], + $this->getParam('dest_column_id'), + 1, + $task['swimlane_id'], + false + ); + } + } + + return in_array(true, $results, true); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return count($data['tasks']) > 0; + } +} diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index cbc606795..946fbf411 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -5,6 +5,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; use Kanboard\Action\TaskAssignDueDateOnCreation; use Kanboard\Action\TaskMoveColumnClosed; +use Kanboard\Action\TaskMoveColumnNotMovedPeriod; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -80,6 +81,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveColumnAssigned($container)); $container['actionManager']->register(new TaskMoveColumnCategoryChange($container)); $container['actionManager']->register(new TaskMoveColumnClosed($container)); + $container['actionManager']->register(new TaskMoveColumnNotMovedPeriod($container)); $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); diff --git a/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php b/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php new file mode 100644 index 000000000..7fa16cf20 --- /dev/null +++ b/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php @@ -0,0 +1,50 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => 3))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => 2))); + + $this->container['db']->table(TaskModel::TABLE)->in('id', array(2, 3))->update(array('date_moved' => strtotime('-10days'))); + + $tasks = $taskFinderModel->getAll(1); + $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1)); + + $action = new TaskMoveColumnNotMovedPeriod($this->container); + $action->setProjectId(1); + $action->setParam('duration', 2); + $action->setParam('src_column_id', 2); + $action->setParam('dest_column_id', 3); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_DAILY_CRONJOB)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(1, $task['column_id']); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(3, $task['column_id']); + + $task = $taskFinderModel->getById(3); + $this->assertNotEmpty($task); + $this->assertEquals(3, $task['column_id']); + } +} From 8e6e335c9d99ff710ecd70dff293f15a25bf9a98 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 19:21:32 -0400 Subject: [PATCH 112/139] Update webhooks documentation --- doc/ru_RU/webhooks.markdown | 99 ++----- doc/webhooks.markdown | 546 +++++++++++++++++++++++++----------- 2 files changed, 401 insertions(+), 244 deletions(-) diff --git a/doc/ru_RU/webhooks.markdown b/doc/ru_RU/webhooks.markdown index c598abf95..dbba08672 100644 --- a/doc/ru_RU/webhooks.markdown +++ b/doc/ru_RU/webhooks.markdown @@ -1,16 +1,10 @@ -Web Hooks -========= - - +Webhooks +======== Webhooks служат для взаимодействия с внешними приложениями. Webhook посылает уведомление стороннему приложению о событиях, которые произошли в Канборд. - - Webhooks могут быть использованы для создания задач вызовом простого URL (Вы можете сделать это и при помощи API) - - - - Обращение к внешнему приложению может происходить автоматически, когда наступает какое-либо событие в Канборд (создана задача, обновлен комментарий и т.д.) @@ -18,89 +12,36 @@ Webhooks служат для взаимодействия с внешними п Как написать webhook приемник во внешнем приложении?[¶](#how-to-write-a-web-hook-receiver "Ссылка на этот заголовок") --------------------------------------------------------------------------------------------------------------------- - - Все внутренние события в Канборде могут быть посланы во внешний URL. - - - Webhook URL (url приемник внешнего приложения) может быть задан в **Настройки** -\> **Webhooks** -\> **Webhook URL** - - - - Когда в Канборде происходит событие, Канборд обращается к указанному URL автоматически - - - - Данные конвертируются в формат JSON и передаются с помощью POST HTTP запроса - - - - Webhook ключ передается в составе запроса в виде строкового параметра. Таким образом, вы можете проверить, что запрос на самом деле пришел из Канборда. - - - - **Созданный вами URL должен среагировать в течении 1 секунды**. Это желательно сделать потому, что запросы являются синхронными (ограничения языка PHP) и возможны тормоза в пользовательском интерфейсе, если скрипт будет слишком медленный! ### Список поддерживаемых событий[¶](#list-of-supported-events "Ссылка на этот заголовок") - - -- comment.create (комментарий.создать) - - - -- comment.update (комментарий.обновить) - - - -- file.create (файл.создать) - - - -- task.move.project (задача.переместить.проект) - - - -- task.move.column (задача.переместить.колонка) - - - -- task.move.position (задача.переместить.место) - - - -- task.move.swimlane (задача.переместить.дорожка) - - - -- task.update (задача.обновить) - - - -- task.create (задача.создать) - - - -- task.close (задача.закрыть) - - - -- task.open (задача.открыть) - - - -- task.assignee\_change (задача.назначить\_изменить) - - - -- subtask.update (подзадача.обновить) - - - -- subtask.create (подзадача.создать) +- comment.create +- comment.update +- comment.delete +- file.create +- task.move.project +- task.move.column +- task.move.position +- task.move.swimlane +- task.update +- task.create +- task.close +- task.open +- task.assignee_change +- subtask.update +- subtask.create +- subtask.delete +- task_internal_link.create_update +- task_internal_link.delete diff --git a/doc/webhooks.markdown b/doc/webhooks.markdown index 628c7e38a..e43ab9ce3 100644 --- a/doc/webhooks.markdown +++ b/doc/webhooks.markdown @@ -1,5 +1,5 @@ -Web Hooks -========= +Webhooks +======== Webhooks are useful to perform actions with external applications. @@ -21,6 +21,7 @@ All internal events of Kanboard can be sent to an external URL. - comment.create - comment.update +- comment.delete - file.create - task.move.project - task.move.column @@ -33,6 +34,9 @@ All internal events of Kanboard can be sent to an external URL. - task.assignee_change - subtask.update - subtask.create +- subtask.delete +- task_internal_link.create_update +- task_internal_link.delete ### Example of HTTP request @@ -43,19 +47,65 @@ Content-Type: application/json Connection: close { - "event_name": "task.move.column", - "event_data": { - "task_id": "1", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "2", - "dst_column_id": "1", - "date_moved": "1431991532", - "recurrence_status": "0", - "recurrence_trigger": "0" - } + "event_name": "task.move.column", + "event_data": { + "task_id": "4", + "task": { + "id": "4", + "reference": "", + "title": "My task", + "description": "", + "date_creation": "1469314356", + "date_completed": null, + "date_modification": "1469315422", + "date_due": "1469491200", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "green", + "project_id": "1", + "column_id": "1", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "priority": "0", + "swimlane_id": "0", + "date_moved": "1469315422", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Backlog", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + }, + "changes": { + "src_column_id": "2", + "dst_column_id": "1", + "date_moved": "1469315398" + }, + "project_id": "1", + "position": 1, + "column_id": "1", + "swimlane_id": "0", + "src_column_id": "2", + "dst_column_id": "1", + "date_moved": "1469315398", + "recurrence_status": "0", + "recurrence_trigger": "0" + } } ``` @@ -80,26 +130,51 @@ Task creation: ```json { - "event_name": "task.create", - "event_data": { - "title": "Demo", - "description": "", - "project_id": "1", - "owner_id": "1", - "category_id": 0, - "swimlane_id": 0, - "column_id": "2", - "color_id": "yellow", - "score": 0, - "time_estimated": 0, - "date_due": 0, - "creator_id": 1, - "date_creation": 1431991532, - "date_modification": 1431991532, - "date_moved": 1431991532, - "position": 1, - "task_id": 1 - } + "event_name": "task.create", + "event_data": { + "task_id": 5, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315481", + "date_due": "0", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "orange", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -107,113 +182,121 @@ Task modification: ```json { - "event_name": "task.update", - "event_data": { - "id": "1", - "title": "Demo", - "description": "", - "date_creation": "1431991532", - "color_id": "yellow", - "project_id": "1", - "column_id": "1", - "owner_id": "1", - "position": "1", - "is_active": "1", - "date_completed": null, - "score": "0", - "date_due": "0", - "category_id": "2", - "creator_id": "1", - "date_modification": 1431991603, - "reference": "", - "date_started": 1431993600, - "time_spent": 0, - "time_estimated": 0, - "swimlane_id": "0", - "date_moved": "1431991572", - "recurrence_status": "0", - "recurrence_trigger": "0", - "recurrence_factor": "0", - "recurrence_timeframe": "0", - "recurrence_basedate": "0", - "recurrence_parent": null, - "recurrence_child": null, - "task_id": "1", - "changes": { - "category_id": "2" + "event_name": "task.update", + "event_data": { + "task_id": "5", + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + }, + "changes": { + "description": "New description", + "color_id": "purple", + "date_due": 1469836800 + } } - } } ``` Task update events have a field called `changes` that contains updated values. -Move a task to another column: - -```json -{ - "event_name": "task.move.column", - "event_data": { - "task_id": "1", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "2", - "dst_column_id": "1", - "date_moved": "1431991532", - "recurrence_status": "0", - "recurrence_trigger": "0" - } -} -``` - -Move a task to another position: - -```json -{ - "event_name": "task.move.position", - "event_data": { - "task_id": "2", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "1", - "dst_column_id": "1", - "date_moved": "1431996905", - "recurrence_status": "0", - "recurrence_trigger": "0" - } -} -``` - Comment creation: ```json { - "event_name": "comment.create", - "event_data": { - "id": 1, - "task_id": "1", - "user_id": "1", - "comment": "test", - "date_creation": 1431991615 - } -} -``` - -Comment modification: - -``` -{ - "event_name": "comment.update", - "event_data": { - "id": "1", - "task_id": "1", - "user_id": "1", - "comment": "test edit" - } + "event_name": "comment.create", + "event_data": { + "comment": { + "id": "1", + "task_id": "5", + "user_id": "1", + "date_creation": "1469315727", + "comment": "My comment.", + "reference": null, + "username": "admin", + "name": null, + "email": null, + "avatar_path": null + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -221,28 +304,65 @@ Subtask creation: ```json { - "event_name": "subtask.create", - "event_data": { - "id": 3, - "task_id": "1", - "title": "Test", - "user_id": "1", - "time_estimated": "2", - "position": 3 - } -} -``` - -Subtask modification: - -```json -{ - "event_name": "subtask.update", - "event_data": { - "id": "1", - "status": 1, - "task_id": "1" - } + "event_name": "subtask.create", + "event_data": { + "subtask": { + "id": "1", + "title": "My subtask", + "status": "0", + "time_estimated": "0", + "time_spent": "0", + "task_id": "5", + "user_id": "1", + "position": "1", + "username": "admin", + "name": null, + "timer_start_date": 0, + "status_name": "Todo", + "is_timer_started": false + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -250,22 +370,118 @@ File upload: ```json { - "event_name": "file.create", - "event_data": { - "task_id": "1", - "name": "test.png" - } + "event_name": "task.file.create", + "event_data": { + "file": { + "id": "1", + "name": "kanboard-latest.zip", + "path": "tasks/5/6f32893e467e76671965b1ec58c06a2440823752", + "is_image": "0", + "task_id": "5", + "date": "1469315613", + "user_id": "1", + "size": "4907308" + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` -Screenshot created: +Task link creation: ```json { - "event_name": "file.create", - "event_data": { - "task_id": "2", - "name": "Screenshot taken May 19, 2015 at 10:56 AM" - } + "event_name": "task_internal_link.create_update", + "event_data": { + "task_link": { + "id": "2", + "opposite_task_id": "5", + "task_id": "4", + "link_id": "3", + "label": "is blocked by", + "opposite_link_id": "2" + }, + "task": { + "id": "4", + "reference": "", + "title": "My task", + "description": "", + "date_creation": "1469314356", + "date_completed": null, + "date_modification": "1469315422", + "date_due": "1469491200", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "green", + "project_id": "1", + "column_id": "1", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "priority": "0", + "swimlane_id": "0", + "date_moved": "1469315422", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Backlog", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` From adb5023cfc075ce5d6f73a4ba5b4ab51f6c500c0 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 20:30:06 -0400 Subject: [PATCH 113/139] Add unit test for ProjectMetricJob --- app/Core/Base.php | 1 + app/ServiceProvider/JobProvider.php | 5 ++ .../ProjectDailySummarySubscriber.php | 7 +-- tests/units/Job/ProjectMetricJobTest.php | 47 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 tests/units/Job/ProjectMetricJobTest.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 6931d93a4..41f5d2e0a 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -157,6 +157,7 @@ use Pimple\Container; * @property \Kanboard\Job\TaskLinkEventJob $taskLinkEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob + * @property \Kanboard\Job\ProjectMetricJob $projectMetricJob * @property \Psr\Log\LoggerInterface $logger * @property \PicoDb\Database $db * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php index 5b42794b4..2194b11c9 100644 --- a/app/ServiceProvider/JobProvider.php +++ b/app/ServiceProvider/JobProvider.php @@ -5,6 +5,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Job\CommentEventJob; use Kanboard\Job\NotificationJob; use Kanboard\Job\ProjectFileEventJob; +use Kanboard\Job\ProjectMetricJob; use Kanboard\Job\SubtaskEventJob; use Kanboard\Job\TaskEventJob; use Kanboard\Job\TaskFileEventJob; @@ -57,6 +58,10 @@ class JobProvider implements ServiceProviderInterface return new NotificationJob($c); }); + $container['projectMetricJob'] = $container->factory(function ($c) { + return new ProjectMetricJob($c); + }); + return $container; } } diff --git a/app/Subscriber/ProjectDailySummarySubscriber.php b/app/Subscriber/ProjectDailySummarySubscriber.php index 7e3c11c37..eaa9d4687 100644 --- a/app/Subscriber/ProjectDailySummarySubscriber.php +++ b/app/Subscriber/ProjectDailySummarySubscriber.php @@ -3,7 +3,6 @@ namespace Kanboard\Subscriber; use Kanboard\Event\TaskEvent; -use Kanboard\Job\ProjectMetricJob; use Kanboard\Model\TaskModel; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -22,9 +21,7 @@ class ProjectDailySummarySubscriber extends BaseSubscriber implements EventSubsc public function execute(TaskEvent $event) { - if (isset($event['project_id'])) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $this->queueManager->push(ProjectMetricJob::getInstance($this->container)->withParams($event['project_id'])); - } + $this->logger->debug('Subscriber executed: '.__METHOD__); + $this->queueManager->push($this->projectMetricJob->withParams($event['task']['project_id'])); } } diff --git a/tests/units/Job/ProjectMetricJobTest.php b/tests/units/Job/ProjectMetricJobTest.php new file mode 100644 index 000000000..e5b0474de --- /dev/null +++ b/tests/units/Job/ProjectMetricJobTest.php @@ -0,0 +1,47 @@ +container); + $projectMetricJob->withParams(123); + + $this->assertSame( + array(123), + $projectMetricJob->getJobParams() + ); + } + + public function testJob() + { + $this->container['projectDailyColumnStatsModel'] = $this + ->getMockBuilder('\Kanboard\Model\ProjectDailyColumnStatsModel') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('updateTotals')) + ->getMock(); + + $this->container['projectDailyStatsModel'] = $this + ->getMockBuilder('\Kanboard\Model\ProjectDailyStatsModel') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('updateTotals')) + ->getMock(); + + $this->container['projectDailyColumnStatsModel'] + ->expects($this->once()) + ->method('updateTotals') + ->with(42, date('Y-m-d')); + + $this->container['projectDailyStatsModel'] + ->expects($this->once()) + ->method('updateTotals') + ->with(42, date('Y-m-d')); + + $job = new ProjectMetricJob($this->container); + $job->execute(42); + } +} From 220bc9cdcc483e71d5df629e9c7eb26c562b969f Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 20:58:16 -0400 Subject: [PATCH 114/139] Add unit test RecurringTaskSubscriber --- app/Subscriber/RecurringTaskSubscriber.php | 14 +- .../RecurringTaskSubscriberTest.php | 164 ++++++++++++++++++ 2 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 tests/units/Subscriber/RecurringTaskSubscriberTest.php diff --git a/app/Subscriber/RecurringTaskSubscriber.php b/app/Subscriber/RecurringTaskSubscriber.php index 21cd3996c..3e2848f84 100644 --- a/app/Subscriber/RecurringTaskSubscriber.php +++ b/app/Subscriber/RecurringTaskSubscriber.php @@ -19,12 +19,13 @@ class RecurringTaskSubscriber extends BaseSubscriber implements EventSubscriberI public function onMove(TaskEvent $event) { $this->logger->debug('Subscriber executed: '.__METHOD__); + $task = $event['task']; - if ($event['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING) { - if ($event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_FIRST_COLUMN && $this->columnModel->getFirstColumnId($event['project_id']) == $event['src_column_id']) { - $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); - } elseif ($event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_LAST_COLUMN && $this->columnModel->getLastColumnId($event['project_id']) == $event['dst_column_id']) { - $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); + if ($task['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING) { + if ($task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_FIRST_COLUMN && $this->columnModel->getFirstColumnId($task['project_id']) == $event['src_column_id']) { + $this->taskRecurrenceModel->duplicateRecurringTask($task['id']); + } elseif ($task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_LAST_COLUMN && $this->columnModel->getLastColumnId($task['project_id']) == $event['dst_column_id']) { + $this->taskRecurrenceModel->duplicateRecurringTask($task['id']); } } } @@ -32,8 +33,9 @@ class RecurringTaskSubscriber extends BaseSubscriber implements EventSubscriberI public function onClose(TaskEvent $event) { $this->logger->debug('Subscriber executed: '.__METHOD__); + $task = $event['task']; - if ($event['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING && $event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_CLOSE) { + if ($task['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING && $task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_CLOSE) { $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); } } diff --git a/tests/units/Subscriber/RecurringTaskSubscriberTest.php b/tests/units/Subscriber/RecurringTaskSubscriberTest.php new file mode 100644 index 000000000..d6aba7cf9 --- /dev/null +++ b/tests/units/Subscriber/RecurringTaskSubscriberTest.php @@ -0,0 +1,164 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceFirstColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_FIRST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('src_column_id' => 1)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceFirstColumnWithWrongColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_FIRST_COLUMN, + 'column_id' => 2, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('src_column_id' => 2)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceLastColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_LAST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('dst_column_id' => 4)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceLastColumnWithWrongColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_LAST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('dst_column_id' => 2)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceOnClose() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_CLOSE, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withChanges(array('is_active' => 0)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } +} From 2a7ca0405cdafe26578326c12cdd6b072e8d90ae Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:14:33 -0400 Subject: [PATCH 115/139] Create new class SubtaskPositionModel --- app/Controller/SubtaskController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 33 -------- app/Model/SubtaskPositionModel.php | 47 +++++++++++ app/ServiceProvider/ClassProvider.php | 1 + tests/units/Model/SubtaskModelTest.php | 65 ---------------- .../units/Model/SubtaskPositionModelTest.php | 77 +++++++++++++++++++ 7 files changed, 127 insertions(+), 99 deletions(-) create mode 100644 app/Model/SubtaskPositionModel.php create mode 100644 tests/units/Model/SubtaskPositionModelTest.php diff --git a/app/Controller/SubtaskController.php b/app/Controller/SubtaskController.php index 93dab5cd0..7502d84f9 100644 --- a/app/Controller/SubtaskController.php +++ b/app/Controller/SubtaskController.php @@ -168,7 +168,7 @@ class SubtaskController extends BaseController $values = $this->request->getJson(); if (! empty($values) && $this->helper->user->hasProjectAccess('SubtaskController', 'movePosition', $project_id)) { - $result = $this->subtaskModel->changePosition($task_id, $values['subtask_id'], $values['position']); + $result = $this->subtaskPositionModel->changePosition($task_id, $values['subtask_id'], $values['position']); $this->response->json(array('result' => $result)); } else { throw new AccessForbiddenException(); diff --git a/app/Core/Base.php b/app/Core/Base.php index 41f5d2e0a..0230b671d 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -90,6 +90,7 @@ use Pimple\Container; * @property \Kanboard\Model\ProjectTaskPriorityModel $projectTaskPriorityModel * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel + * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel * @property \Kanboard\Model\TagDuplicationModel $tagDuplicationModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index f3fc72ba8..5a4e87a22 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -272,39 +272,6 @@ class SubtaskModel extends Base return $this->db->table(self::TABLE)->eq('task_id', $task_id)->update(array('status' => self::STATUS_DONE)); } - /** - * Save subtask position - * - * @access public - * @param integer $task_id - * @param integer $subtask_id - * @param integer $position - * @return boolean - */ - public function changePosition($task_id, $subtask_id, $position) - { - if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('task_id', $task_id)->count()) { - return false; - } - - $subtask_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id'); - $offset = 1; - $results = array(); - - foreach ($subtask_ids as $current_subtask_id) { - if ($offset == $position) { - $offset++; - } - - $results[] = $this->db->table(self::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset)); - $offset++; - } - - $results[] = $this->db->table(self::TABLE)->eq('id', $subtask_id)->update(array('position' => $position)); - - return !in_array(false, $results, true); - } - /** * Change the status of subtask * diff --git a/app/Model/SubtaskPositionModel.php b/app/Model/SubtaskPositionModel.php new file mode 100644 index 000000000..3c26465de --- /dev/null +++ b/app/Model/SubtaskPositionModel.php @@ -0,0 +1,47 @@ + $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->count()) { + return false; + } + + $subtask_ids = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id'); + $offset = 1; + $results = array(); + + foreach ($subtask_ids as $current_subtask_id) { + if ($offset == $position) { + $offset++; + } + + $results[] = $this->db->table(SubtaskModel::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset)); + $offset++; + } + + $results[] = $this->db->table(SubtaskModel::TABLE)->eq('id', $subtask_id)->update(array('position' => $position)); + + return !in_array(false, $results, true); + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index e32c0d437..d1415d8cb 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -60,6 +60,7 @@ class ClassProvider implements ServiceProviderInterface 'ProjectUserRoleModel', 'RememberMeSessionModel', 'SubtaskModel', + 'SubtaskPositionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', 'TagDuplicationModel', diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 7e4386513..3b25bb3bb 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -229,71 +229,6 @@ class SubtaskModelTest extends Base $this->assertEquals(2, $subtasks[1]['position']); } - public function testChangePosition() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); - $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1))); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(2, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 3, 2)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(3, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(2, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 2, 1)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(2, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(1, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 2, 2)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(2, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 1, 3)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(2, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(3, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(1, $subtasks[2]['id']); - - $this->assertFalse($subtaskModel->changePosition(1, 2, 0)); - $this->assertFalse($subtaskModel->changePosition(1, 2, 4)); - } - public function testConvertToTask() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskPositionModelTest.php b/tests/units/Model/SubtaskPositionModelTest.php new file mode 100644 index 000000000..924123920 --- /dev/null +++ b/tests/units/Model/SubtaskPositionModelTest.php @@ -0,0 +1,77 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskPositionModel = new SubtaskPositionModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1))); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(2, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 3, 2)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(3, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(2, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 2, 1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(2, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(1, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 2, 2)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(2, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 1, 3)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(2, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(3, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(1, $subtasks[2]['id']); + + $this->assertFalse($subtaskPositionModel->changePosition(1, 2, 0)); + $this->assertFalse($subtaskPositionModel->changePosition(1, 2, 4)); + } +} From f216e345ba2ad7486037c393c0475a1371ca2b00 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:22:24 -0400 Subject: [PATCH 116/139] Create new class SubtaskTaskConversionModel --- app/Controller/SubtaskConverterController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 27 ------------ app/Model/SubtaskTaskConversionModel.php | 41 +++++++++++++++++++ app/ServiceProvider/ClassProvider.php | 1 + tests/units/Model/SubtaskModelTest.php | 24 ----------- .../Model/SubtaskTaskConversionModelTest.php | 37 +++++++++++++++++ 7 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 app/Model/SubtaskTaskConversionModel.php create mode 100644 tests/units/Model/SubtaskTaskConversionModelTest.php diff --git a/app/Controller/SubtaskConverterController.php b/app/Controller/SubtaskConverterController.php index 65bcd2da4..404c50d0e 100644 --- a/app/Controller/SubtaskConverterController.php +++ b/app/Controller/SubtaskConverterController.php @@ -26,7 +26,7 @@ class SubtaskConverterController extends BaseController $project = $this->getProject(); $subtask = $this->getSubtask(); - $task_id = $this->subtaskModel->convertToTask($project['id'], $subtask['id']); + $task_id = $this->subtaskTaskConversionModel->convertToTask($project['id'], $subtask['id']); if ($task_id !== false) { $this->flash->success(t('Subtask converted to task successfully.')); diff --git a/app/Core/Base.php b/app/Core/Base.php index 0230b671d..8b9bf0855 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -91,6 +91,7 @@ use Pimple\Container; * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel + * @property \Kanboard\Model\SubtaskTaskConversionModel $subtaskTaskConversionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel * @property \Kanboard\Model\TagDuplicationModel $tagDuplicationModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 5a4e87a22..2ac6095c8 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -368,31 +368,4 @@ class SubtaskModel extends Base } }); } - - /** - * Convert a subtask to a task - * - * @access public - * @param integer $project_id - * @param integer $subtask_id - * @return integer - */ - public function convertToTask($project_id, $subtask_id) - { - $subtask = $this->getById($subtask_id); - - $task_id = $this->taskCreationModel->create(array( - 'project_id' => $project_id, - 'title' => $subtask['title'], - 'time_estimated' => $subtask['time_estimated'], - 'time_spent' => $subtask['time_spent'], - 'owner_id' => $subtask['user_id'], - )); - - if ($task_id !== false) { - $this->remove($subtask_id); - } - - return $task_id; - } } diff --git a/app/Model/SubtaskTaskConversionModel.php b/app/Model/SubtaskTaskConversionModel.php new file mode 100644 index 000000000..8bf83d76a --- /dev/null +++ b/app/Model/SubtaskTaskConversionModel.php @@ -0,0 +1,41 @@ +subtaskModel->getById($subtask_id); + + $task_id = $this->taskCreationModel->create(array( + 'project_id' => $project_id, + 'title' => $subtask['title'], + 'time_estimated' => $subtask['time_estimated'], + 'time_spent' => $subtask['time_spent'], + 'owner_id' => $subtask['user_id'], + )); + + if ($task_id !== false) { + $this->subtaskModel->remove($subtask_id); + } + + return $task_id; + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index d1415d8cb..ad69d5fbc 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -61,6 +61,7 @@ class ClassProvider implements ServiceProviderInterface 'RememberMeSessionModel', 'SubtaskModel', 'SubtaskPositionModel', + 'SubtaskTaskConversionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', 'TagDuplicationModel', diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 3b25bb3bb..d270e177c 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -229,30 +229,6 @@ class SubtaskModelTest extends Base $this->assertEquals(2, $subtasks[1]['position']); } - public function testConvertToTask() - { - $taskCreationModel = new TaskCreationModel($this->container); - $taskFinderModel = new TaskFinderModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1, 'time_spent' => 2, 'time_estimated' => 3))); - $task_id = $subtaskModel->convertToTask(1, 1); - - $this->assertNotFalse($task_id); - $this->assertEmpty($subtaskModel->getById(1)); - - $task = $taskFinderModel->getById($task_id); - $this->assertEquals('subtask #1', $task['title']); - $this->assertEquals(1, $task['project_id']); - $this->assertEquals(1, $task['owner_id']); - $this->assertEquals(2, $task['time_spent']); - $this->assertEquals(3, $task['time_estimated']); - } - public function testGetProjectId() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskTaskConversionModelTest.php b/tests/units/Model/SubtaskTaskConversionModelTest.php new file mode 100644 index 000000000..51a623b2d --- /dev/null +++ b/tests/units/Model/SubtaskTaskConversionModelTest.php @@ -0,0 +1,37 @@ +container); + $taskFinderModel = new TaskFinderModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskConversion = new SubtaskTaskConversionModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1, 'time_spent' => 2, 'time_estimated' => 3))); + $task_id = $subtaskConversion->convertToTask(1, 1); + + $this->assertNotFalse($task_id); + $this->assertEmpty($subtaskModel->getById(1)); + + $task = $taskFinderModel->getById($task_id); + $this->assertEquals('subtask #1', $task['title']); + $this->assertEquals(1, $task['project_id']); + $this->assertEquals(1, $task['owner_id']); + $this->assertEquals(2, $task['time_spent']); + $this->assertEquals(3, $task['time_estimated']); + } +} From 24555080fd3ca8607f0a798b5a0e4be98ff131f8 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:48:59 -0400 Subject: [PATCH 117/139] Create new class SubtaskStatusModel --- .../SubtaskRestrictionController.php | 2 +- app/Controller/SubtaskStatusController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 184 ++++---------- app/Model/SubtaskStatusModel.php | 85 +++++++ app/Model/TaskStatusModel.php | 2 +- app/ServiceProvider/ClassProvider.php | 1 + app/Subscriber/BootstrapSubscriber.php | 2 +- tests/units/Model/SubtaskModelTest.php | 110 -------- tests/units/Model/SubtaskStatusModelTest.php | 123 +++++++++ .../Model/SubtaskTimeTrackingModelTest.php | 240 ++++++++++++++++++ tests/units/Model/SubtaskTimeTrackingTest.php | 240 ------------------ 12 files changed, 506 insertions(+), 486 deletions(-) create mode 100644 app/Model/SubtaskStatusModel.php create mode 100644 tests/units/Model/SubtaskStatusModelTest.php create mode 100644 tests/units/Model/SubtaskTimeTrackingModelTest.php delete mode 100644 tests/units/Model/SubtaskTimeTrackingTest.php diff --git a/app/Controller/SubtaskRestrictionController.php b/app/Controller/SubtaskRestrictionController.php index 084fc0d9c..cb642e1c3 100644 --- a/app/Controller/SubtaskRestrictionController.php +++ b/app/Controller/SubtaskRestrictionController.php @@ -27,7 +27,7 @@ class SubtaskRestrictionController extends BaseController SubtaskModel::STATUS_TODO => t('Todo'), SubtaskModel::STATUS_DONE => t('Done'), ), - 'subtask_inprogress' => $this->subtaskModel->getSubtaskInProgress($this->userSession->getId()), + 'subtask_inprogress' => $this->subtaskStatusModel->getSubtaskInProgress($this->userSession->getId()), 'subtask' => $subtask, 'task' => $task, ))); diff --git a/app/Controller/SubtaskStatusController.php b/app/Controller/SubtaskStatusController.php index 699951fe3..d4d356c36 100644 --- a/app/Controller/SubtaskStatusController.php +++ b/app/Controller/SubtaskStatusController.php @@ -20,7 +20,7 @@ class SubtaskStatusController extends BaseController $task = $this->getTask(); $subtask = $this->getSubtask(); - $status = $this->subtaskModel->toggleStatus($subtask['id']); + $status = $this->subtaskStatusModel->toggleStatus($subtask['id']); if ($this->request->getIntegerParam('refresh-table') === 0) { $subtask['status'] = $status; diff --git a/app/Core/Base.php b/app/Core/Base.php index 8b9bf0855..563013bda 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -91,6 +91,7 @@ use Pimple\Container; * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel + * @property \Kanboard\Model\SubtaskStatusModel $subtaskStatusModel * @property \Kanboard\Model\SubtaskTaskConversionModel $subtaskTaskConversionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 2ac6095c8..568e27a46 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -21,25 +21,13 @@ class SubtaskModel extends Base const TABLE = 'subtasks'; /** - * Task "done" status - * - * @var integer - */ - const STATUS_DONE = 2; - - /** - * Task "in progress" status - * - * @var integer - */ - const STATUS_INPROGRESS = 1; - - /** - * Task "todo" status + * Subtask status * * @var integer */ const STATUS_TODO = 0; + const STATUS_INPROGRESS = 1; + const STATUS_DONE = 2; /** * Events @@ -81,26 +69,6 @@ class SubtaskModel extends Base ); } - /** - * Add subtask status status to the resultset - * - * @access public - * @param array $subtasks Subtasks - * @return array - */ - public function addStatusName(array $subtasks) - { - $status = $this->getStatusList(); - - foreach ($subtasks as &$subtask) { - $subtask['status_name'] = $status[$subtask['status']]; - $subtask['timer_start_date'] = isset($subtask['timer_start_date']) ? $subtask['timer_start_date'] : 0; - $subtask['is_timer_started'] = ! empty($subtask['timer_start_date']); - } - - return $subtasks; - } - /** * Get the query to fetch subtasks assigned to a user * @@ -176,35 +144,6 @@ class SubtaskModel extends Base return $this->db->table(self::TABLE)->eq('id', $subtask_id)->findOne(); } - /** - * Prepare data before insert/update - * - * @access public - * @param array $values Form values - */ - public function prepare(array &$values) - { - $this->helper->model->removeFields($values, array('another_subtask')); - $this->helper->model->resetFields($values, array('time_estimated', 'time_spent')); - } - - /** - * Prepare data before insert - * - * @access public - * @param array $values Form values - */ - public function prepareCreation(array &$values) - { - $this->prepare($values); - - $values['position'] = $this->getLastPosition($values['task_id']) + 1; - $values['status'] = isset($values['status']) ? $values['status'] : self::STATUS_TODO; - $values['time_estimated'] = isset($values['time_estimated']) ? $values['time_estimated'] : 0; - $values['time_spent'] = isset($values['time_spent']) ? $values['time_spent'] : 0; - $values['user_id'] = isset($values['user_id']) ? $values['user_id'] : 0; - } - /** * Get the position of the last column for a given project * @@ -260,74 +199,6 @@ class SubtaskModel extends Base return $result; } - /** - * Close all subtasks of a task - * - * @access public - * @param integer $task_id - * @return boolean - */ - public function closeAll($task_id) - { - return $this->db->table(self::TABLE)->eq('task_id', $task_id)->update(array('status' => self::STATUS_DONE)); - } - - /** - * Change the status of subtask - * - * @access public - * @param integer $subtask_id - * @return boolean|integer - */ - public function toggleStatus($subtask_id) - { - $subtask = $this->getById($subtask_id); - $status = ($subtask['status'] + 1) % 3; - - $values = array( - 'id' => $subtask['id'], - 'status' => $status, - 'task_id' => $subtask['task_id'], - ); - - if (empty($subtask['user_id']) && $this->userSession->isLogged()) { - $values['user_id'] = $this->userSession->getId(); - } - - return $this->update($values) ? $status : false; - } - - /** - * Get the subtask in progress for this user - * - * @access public - * @param integer $user_id - * @return array - */ - public function getSubtaskInProgress($user_id) - { - return $this->db->table(self::TABLE) - ->eq('status', self::STATUS_INPROGRESS) - ->eq('user_id', $user_id) - ->findOne(); - } - - /** - * Return true if the user have a subtask in progress - * - * @access public - * @param integer $user_id - * @return boolean - */ - public function hasSubtaskInProgress($user_id) - { - return $this->configModel->get('subtask_restriction') == 1 && - $this->db->table(self::TABLE) - ->eq('status', self::STATUS_INPROGRESS) - ->eq('user_id', $user_id) - ->exists(); - } - /** * Remove * @@ -368,4 +239,53 @@ class SubtaskModel extends Base } }); } + + /** + * Prepare data before insert/update + * + * @access protected + * @param array $values Form values + */ + protected function prepare(array &$values) + { + $this->helper->model->removeFields($values, array('another_subtask')); + $this->helper->model->resetFields($values, array('time_estimated', 'time_spent')); + } + + /** + * Prepare data before insert + * + * @access protected + * @param array $values Form values + */ + protected function prepareCreation(array &$values) + { + $this->prepare($values); + + $values['position'] = $this->getLastPosition($values['task_id']) + 1; + $values['status'] = isset($values['status']) ? $values['status'] : self::STATUS_TODO; + $values['time_estimated'] = isset($values['time_estimated']) ? $values['time_estimated'] : 0; + $values['time_spent'] = isset($values['time_spent']) ? $values['time_spent'] : 0; + $values['user_id'] = isset($values['user_id']) ? $values['user_id'] : 0; + } + + /** + * Add subtask status status to the resultset + * + * @access public + * @param array $subtasks Subtasks + * @return array + */ + public function addStatusName(array $subtasks) + { + $status = $this->getStatusList(); + + foreach ($subtasks as &$subtask) { + $subtask['status_name'] = $status[$subtask['status']]; + $subtask['timer_start_date'] = isset($subtask['timer_start_date']) ? $subtask['timer_start_date'] : 0; + $subtask['is_timer_started'] = ! empty($subtask['timer_start_date']); + } + + return $subtasks; + } } diff --git a/app/Model/SubtaskStatusModel.php b/app/Model/SubtaskStatusModel.php new file mode 100644 index 000000000..26cbb67dd --- /dev/null +++ b/app/Model/SubtaskStatusModel.php @@ -0,0 +1,85 @@ +db->table(SubtaskModel::TABLE) + ->eq('status', SubtaskModel::STATUS_INPROGRESS) + ->eq('user_id', $user_id) + ->findOne(); + } + + /** + * Return true if the user have a subtask in progress + * + * @access public + * @param integer $user_id + * @return boolean + */ + public function hasSubtaskInProgress($user_id) + { + return $this->configModel->get('subtask_restriction') == 1 && + $this->db->table(SubtaskModel::TABLE) + ->eq('status', SubtaskModel::STATUS_INPROGRESS) + ->eq('user_id', $user_id) + ->exists(); + } + + /** + * Change the status of subtask + * + * @access public + * @param integer $subtask_id + * @return boolean|integer + */ + public function toggleStatus($subtask_id) + { + $subtask = $this->subtaskModel->getById($subtask_id); + $status = ($subtask['status'] + 1) % 3; + + $values = array( + 'id' => $subtask['id'], + 'status' => $status, + 'task_id' => $subtask['task_id'], + ); + + if (empty($subtask['user_id']) && $this->userSession->isLogged()) { + $values['user_id'] = $this->userSession->getId(); + } + + return $this->subtaskModel->update($values) ? $status : false; + } + + /** + * Close all subtasks of a task + * + * @access public + * @param integer $task_id + * @return boolean + */ + public function closeAll($task_id) + { + return $this->db + ->table(SubtaskModel::TABLE) + ->eq('task_id', $task_id) + ->update(array('status' => SubtaskModel::STATUS_DONE)); + } +} diff --git a/app/Model/TaskStatusModel.php b/app/Model/TaskStatusModel.php index ea304beb0..dc1146982 100644 --- a/app/Model/TaskStatusModel.php +++ b/app/Model/TaskStatusModel.php @@ -45,7 +45,7 @@ class TaskStatusModel extends Base */ public function close($task_id) { - $this->subtaskModel->closeAll($task_id); + $this->subtaskStatusModel->closeAll($task_id); return $this->changeStatus($task_id, TaskModel::STATUS_CLOSED, time(), TaskModel::EVENT_CLOSE); } diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index ad69d5fbc..9a71148b2 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -61,6 +61,7 @@ class ClassProvider implements ServiceProviderInterface 'RememberMeSessionModel', 'SubtaskModel', 'SubtaskPositionModel', + 'SubtaskStatusModel', 'SubtaskTaskConversionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', diff --git a/app/Subscriber/BootstrapSubscriber.php b/app/Subscriber/BootstrapSubscriber.php index 7d12e9ae1..3618f30f7 100644 --- a/app/Subscriber/BootstrapSubscriber.php +++ b/app/Subscriber/BootstrapSubscriber.php @@ -21,7 +21,7 @@ class BootstrapSubscriber extends BaseSubscriber implements EventSubscriberInter $this->actionManager->attachEvents(); if ($this->userSession->isLogged()) { - $this->sessionStorage->hasSubtaskInProgress = $this->subtaskModel->hasSubtaskInProgress($this->userSession->getId()); + $this->sessionStorage->hasSubtaskInProgress = $this->subtaskStatusModel->hasSubtaskInProgress($this->userSession->getId()); } } diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index d270e177c..23183d229 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -5,7 +5,6 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\ProjectModel; -use Kanboard\Model\TaskFinderModel; class SubtaskModelTest extends Base { @@ -74,115 +73,6 @@ class SubtaskModelTest extends Base $this->assertEmpty($subtask); } - public function testToggleStatusWithoutSession() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - } - - public function testToggleStatusWithSession() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - // Set the current logged user - $this->container['sessionStorage']->user = array('id' => 1); - - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - } - - public function testCloseAll() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); - - $this->assertTrue($subtaskModel->closeAll(1)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertNotEmpty($subtasks); - - foreach ($subtasks as $subtask) { - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - } - } - public function testDuplicate() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskStatusModelTest.php b/tests/units/Model/SubtaskStatusModelTest.php new file mode 100644 index 000000000..af4c39553 --- /dev/null +++ b/tests/units/Model/SubtaskStatusModelTest.php @@ -0,0 +1,123 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + } + + public function testToggleStatusWithSession() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + // Set the current logged user + $this->container['sessionStorage']->user = array('id' => 1); + + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + } + + public function testCloseAll() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + + $this->assertTrue($subtaskStatusModel->closeAll(1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + + foreach ($subtasks as $subtask) { + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + } + } +} diff --git a/tests/units/Model/SubtaskTimeTrackingModelTest.php b/tests/units/Model/SubtaskTimeTrackingModelTest.php new file mode 100644 index 000000000..cfee5b14d --- /dev/null +++ b/tests/units/Model/SubtaskTimeTrackingModelTest.php @@ -0,0 +1,240 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->hasTimer(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->hasTimer(1, 1)); + $this->assertFalse($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $this->assertFalse($subtaskTimeTrackingModel->hasTimer(1, 1)); + } + + public function testGetTimerStatus() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->container['sessionStorage']->user = array('id' => 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1))); + + // Nothing started + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(0, $subtasks[0]['timer_start_date']); + $this->assertFalse($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(0, $subtask['timer_start_date']); + $this->assertFalse($subtask['is_timer_started']); + + // Start the clock + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(time(), $subtasks[0]['timer_start_date'], '', 3); + $this->assertTrue($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(time(), $subtask['timer_start_date'], '', 3); + $this->assertTrue($subtask['is_timer_started']); + + // Stop the clock + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(0, $subtasks[0]['timer_start_date']); + $this->assertFalse($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(0, $subtask['timer_start_date']); + $this->assertFalse($subtask['is_timer_started']); + } + + public function testLogStartTime() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(1, $timesheet); + $this->assertNotEmpty($timesheet[0]['start']); + $this->assertEmpty($timesheet[0]['end']); + $this->assertEquals(1, $timesheet[0]['user_id']); + $this->assertEquals(1, $timesheet[0]['subtask_id']); + } + + public function testLogStartEnd() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + // No start time + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertEmpty($timesheet); + + // Log start and end time + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + sleep(1); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(1, $timesheet); + $this->assertNotEmpty($timesheet[0]['start']); + $this->assertNotEmpty($timesheet[0]['end']); + $this->assertEquals(1, $timesheet[0]['user_id']); + $this->assertEquals(1, $timesheet[0]['subtask_id']); + $this->assertNotEquals($timesheet[0]['start'], $timesheet[0]['end']); + } + + public function testCalculateSubtaskTime() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2, 'time_estimated' => 3.3))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 1.1, 'time_estimated' => 4.4))); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(1); + $this->assertCount(2, $time); + $this->assertEquals(3.3, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(7.7, $time['time_estimated'], 'Total estimated', 0.01); + } + + public function testUpdateSubtaskTimeSpent() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(2, 1)); + + // Fake start time + $this->container['db']->table(SubtaskTimeTrackingModel::TABLE)->update(array('start' => time() - 3600)); + + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(2, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(2, $timesheet); + $this->assertEquals(3600, $timesheet[0]['end'] - $timesheet[0]['start'], 'Wrong timestamps', 1); + $this->assertEquals(3600, $timesheet[1]['end'] - $timesheet[1]['start'], 'Wrong timestamps', 1); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(1); + $this->assertEquals(4.2, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(2); + $this->assertEquals(0, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); + } + + public function testUpdateTaskTimeTracking() + { + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test 2', 'project_id' => 1, 'time_estimated' => 1.5, 'time_spent' => 0.5))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'test 3', 'project_id' => 1, 'time_estimated' => 4, 'time_spent' => 2))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_spent' => 2.2))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 1))); + + $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 2, 'time_spent' => 3.4))); + $this->assertEquals(4, $subtaskModel->create(array('title' => 'subtask #4', 'task_id' => 2, 'time_estimated' => 1.25))); + + $this->assertEquals(5, $subtaskModel->create(array('title' => 'subtask #5', 'task_id' => 3, 'time_spent' => 8))); + + $subtaskTimeTrackingModel->updateTaskTimeTracking(1); + $subtaskTimeTrackingModel->updateTaskTimeTracking(2); + $subtaskTimeTrackingModel->updateTaskTimeTracking(3); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(2.2, $task['time_spent'], 'Total spent', 0.01); + $this->assertEquals(1, $task['time_estimated'], 'Total estimated', 0.01); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(3.4, $task['time_spent'], 'Total spent', 0.01); + $this->assertEquals(1.25, $task['time_estimated'], 'Total estimated', 0.01); + + $task = $taskFinderModel->getById(3); + $this->assertNotEmpty($task); + $this->assertEquals(0, $task['time_estimated']); + $this->assertEquals(8, $task['time_spent']); + + $this->assertTrue($subtaskModel->remove(3)); + $this->assertTrue($subtaskModel->remove(4)); + + $subtaskTimeTrackingModel->updateTaskTimeTracking(2); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(0, $task['time_estimated']); + $this->assertEquals(0, $task['time_spent']); + } +} diff --git a/tests/units/Model/SubtaskTimeTrackingTest.php b/tests/units/Model/SubtaskTimeTrackingTest.php deleted file mode 100644 index d5ae62ae5..000000000 --- a/tests/units/Model/SubtaskTimeTrackingTest.php +++ /dev/null @@ -1,240 +0,0 @@ -container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - $this->assertFalse($st->hasTimer(1, 1)); - $this->assertTrue($st->logStartTime(1, 1)); - $this->assertTrue($st->hasTimer(1, 1)); - $this->assertFalse($st->logStartTime(1, 1)); - $this->assertTrue($st->logEndTime(1, 1)); - $this->assertFalse($st->hasTimer(1, 1)); - } - - public function testGetTimerStatus() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->container['sessionStorage']->user = array('id' => 1); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1))); - - // Nothing started - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(0, $subtasks[0]['timer_start_date']); - $this->assertFalse($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(0, $subtask['timer_start_date']); - $this->assertFalse($subtask['is_timer_started']); - - // Start the clock - $this->assertTrue($st->logStartTime(1, 1)); - - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(time(), $subtasks[0]['timer_start_date'], '', 3); - $this->assertTrue($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(time(), $subtask['timer_start_date'], '', 3); - $this->assertTrue($subtask['is_timer_started']); - - // Stop the clock - $this->assertTrue($st->logEndTime(1, 1)); - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(0, $subtasks[0]['timer_start_date']); - $this->assertFalse($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(0, $subtask['timer_start_date']); - $this->assertFalse($subtask['is_timer_started']); - } - - public function testLogStartTime() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - $this->assertTrue($st->logStartTime(1, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(1, $timesheet); - $this->assertNotEmpty($timesheet[0]['start']); - $this->assertEmpty($timesheet[0]['end']); - $this->assertEquals(1, $timesheet[0]['user_id']); - $this->assertEquals(1, $timesheet[0]['subtask_id']); - } - - public function testLogStartEnd() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - // No start time - $this->assertTrue($st->logEndTime(1, 1)); - $timesheet = $st->getUserTimesheet(1); - $this->assertEmpty($timesheet); - - // Log start and end time - $this->assertTrue($st->logStartTime(1, 1)); - sleep(1); - $this->assertTrue($st->logEndTime(1, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(1, $timesheet); - $this->assertNotEmpty($timesheet[0]['start']); - $this->assertNotEmpty($timesheet[0]['end']); - $this->assertEquals(1, $timesheet[0]['user_id']); - $this->assertEquals(1, $timesheet[0]['subtask_id']); - $this->assertNotEquals($timesheet[0]['start'], $timesheet[0]['end']); - } - - public function testCalculateSubtaskTime() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2, 'time_estimated' => 3.3))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 1.1, 'time_estimated' => 4.4))); - - $time = $st->calculateSubtaskTime(1); - $this->assertCount(2, $time); - $this->assertEquals(3.3, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(7.7, $time['time_estimated'], 'Total estimated', 0.01); - } - - public function testUpdateSubtaskTimeSpent() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1))); - - $this->assertTrue($st->logStartTime(1, 1)); - $this->assertTrue($st->logStartTime(2, 1)); - - // Fake start time - $this->container['db']->table(SubtaskTimeTrackingModel::TABLE)->update(array('start' => time() - 3600)); - - $this->assertTrue($st->logEndTime(1, 1)); - $this->assertTrue($st->logEndTime(2, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(2, $timesheet); - $this->assertEquals(3600, $timesheet[0]['end'] - $timesheet[0]['start'], 'Wrong timestamps', 1); - $this->assertEquals(3600, $timesheet[1]['end'] - $timesheet[1]['start'], 'Wrong timestamps', 1); - - $time = $st->calculateSubtaskTime(1); - $this->assertEquals(4.2, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); - - $time = $st->calculateSubtaskTime(2); - $this->assertEquals(0, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); - } - - public function testUpdateTaskTimeTracking() - { - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'test 2', 'project_id' => 1, 'time_estimated' => 1.5, 'time_spent' => 0.5))); - $this->assertEquals(3, $tc->create(array('title' => 'test 3', 'project_id' => 1, 'time_estimated' => 4, 'time_spent' => 2))); - - $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_spent' => 2.2))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 1))); - - $this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 2, 'time_spent' => 3.4))); - $this->assertEquals(4, $s->create(array('title' => 'subtask #4', 'task_id' => 2, 'time_estimated' => 1.25))); - - $this->assertEquals(5, $s->create(array('title' => 'subtask #5', 'task_id' => 3, 'time_spent' => 8))); - - $st->updateTaskTimeTracking(1); - $st->updateTaskTimeTracking(2); - $st->updateTaskTimeTracking(3); - - $task = $tf->getById(1); - $this->assertNotEmpty($task); - $this->assertEquals(2.2, $task['time_spent'], 'Total spent', 0.01); - $this->assertEquals(1, $task['time_estimated'], 'Total estimated', 0.01); - - $task = $tf->getById(2); - $this->assertNotEmpty($task); - $this->assertEquals(3.4, $task['time_spent'], 'Total spent', 0.01); - $this->assertEquals(1.25, $task['time_estimated'], 'Total estimated', 0.01); - - $task = $tf->getById(3); - $this->assertNotEmpty($task); - $this->assertEquals(0, $task['time_estimated']); - $this->assertEquals(8, $task['time_spent']); - - $this->assertTrue($s->remove(3)); - $this->assertTrue($s->remove(4)); - - $st->updateTaskTimeTracking(2); - - $task = $tf->getById(2); - $this->assertNotEmpty($task); - $this->assertEquals(0, $task['time_estimated']); - $this->assertEquals(0, $task['time_spent']); - } -} From 5884c65a02a13dd396525d0b8d1720d1c062a96e Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 22:50:20 -0400 Subject: [PATCH 118/139] Remove SubtaskTimeTrackingSubscriber --- app/Model/SubtaskModel.php | 15 ++++-- app/Model/SubtaskStatusModel.php | 3 ++ app/Model/SubtaskTimeTrackingModel.php | 23 ++++++++- .../EventDispatcherProvider.php | 2 - .../SubtaskTimeTrackingSubscriber.php | 48 ------------------- tests/units/Job/SubtaskEventJobTest.php | 2 +- tests/units/Model/SubtaskModelTest.php | 42 +++++++++++++++- .../Model/SubtaskTimeTrackingModelTest.php | 37 ++++++++++++++ 8 files changed, 114 insertions(+), 58 deletions(-) delete mode 100644 app/Subscriber/SubtaskTimeTrackingSubscriber.php diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 568e27a46..608ffce74 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -173,6 +173,7 @@ class SubtaskModel extends Base $subtask_id = $this->db->table(self::TABLE)->persist($values); if ($subtask_id !== false) { + $this->subtaskTimeTrackingModel->updateTaskTimeTracking($values['task_id']); $this->queueManager->push($this->subtaskEventJob->withParams($subtask_id, self::EVENT_CREATE)); } @@ -183,17 +184,21 @@ class SubtaskModel extends Base * Update * * @access public - * @param array $values Form values - * @param bool $fire_events If true, will be called an event + * @param array $values + * @param bool $fire_event * @return bool */ - public function update(array $values, $fire_events = true) + public function update(array $values, $fire_event = true) { $this->prepare($values); $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values); - if ($result && $fire_events) { - $this->queueManager->push($this->subtaskEventJob->withParams($values['id'], self::EVENT_UPDATE, $values)); + if ($result) { + $this->subtaskTimeTrackingModel->updateTaskTimeTracking($values['task_id']); + + if ($fire_event) { + $this->queueManager->push($this->subtaskEventJob->withParams($values['id'], self::EVENT_UPDATE, $values)); + } } return $result; diff --git a/app/Model/SubtaskStatusModel.php b/app/Model/SubtaskStatusModel.php index 26cbb67dd..c99d60553 100644 --- a/app/Model/SubtaskStatusModel.php +++ b/app/Model/SubtaskStatusModel.php @@ -63,8 +63,11 @@ class SubtaskStatusModel extends Base if (empty($subtask['user_id']) && $this->userSession->isLogged()) { $values['user_id'] = $this->userSession->getId(); + $subtask['user_id'] = $values['user_id']; } + $this->subtaskTimeTrackingModel->toggleTimer($subtask_id, $subtask['user_id'], $status); + return $this->subtaskModel->update($values) ? $status : false; } diff --git a/app/Model/SubtaskTimeTrackingModel.php b/app/Model/SubtaskTimeTrackingModel.php index 062e594a7..3b1b97e41 100644 --- a/app/Model/SubtaskTimeTrackingModel.php +++ b/app/Model/SubtaskTimeTrackingModel.php @@ -159,6 +159,28 @@ class SubtaskTimeTrackingModel extends Base return $this->db->table(self::TABLE)->eq('subtask_id', $subtask_id)->eq('user_id', $user_id)->eq('end', 0)->exists(); } + /** + * Start or stop timer according to subtask status + * + * @access public + * @param integer $subtask_id + * @param integer $user_id + * @param integer $status + * @return boolean + */ + public function toggleTimer($subtask_id, $user_id, $status) + { + if ($this->configModel->get('subtask_time_tracking') == 1) { + if ($status == SubtaskModel::STATUS_INPROGRESS) { + return $this->subtaskTimeTrackingModel->logStartTime($subtask_id, $user_id); + } elseif ($status == SubtaskModel::STATUS_DONE) { + return $this->subtaskTimeTrackingModel->logEndTime($subtask_id, $user_id); + } + } + + return false; + } + /** * Log start time * @@ -252,7 +274,6 @@ class SubtaskTimeTrackingModel extends Base { $subtask = $this->subtaskModel->getById($subtask_id); - // Fire the event subtask.update return $this->subtaskModel->update(array( 'id' => $subtask['id'], 'time_spent' => $subtask['time_spent'] + $time_spent, diff --git a/app/ServiceProvider/EventDispatcherProvider.php b/app/ServiceProvider/EventDispatcherProvider.php index 57543fe4f..ebf42cbfd 100644 --- a/app/ServiceProvider/EventDispatcherProvider.php +++ b/app/ServiceProvider/EventDispatcherProvider.php @@ -11,7 +11,6 @@ use Kanboard\Subscriber\BootstrapSubscriber; use Kanboard\Subscriber\NotificationSubscriber; use Kanboard\Subscriber\ProjectDailySummarySubscriber; use Kanboard\Subscriber\ProjectModificationDateSubscriber; -use Kanboard\Subscriber\SubtaskTimeTrackingSubscriber; use Kanboard\Subscriber\TransitionSubscriber; use Kanboard\Subscriber\RecurringTaskSubscriber; @@ -31,7 +30,6 @@ class EventDispatcherProvider implements ServiceProviderInterface $container['dispatcher']->addSubscriber(new ProjectDailySummarySubscriber($container)); $container['dispatcher']->addSubscriber(new ProjectModificationDateSubscriber($container)); $container['dispatcher']->addSubscriber(new NotificationSubscriber($container)); - $container['dispatcher']->addSubscriber(new SubtaskTimeTrackingSubscriber($container)); $container['dispatcher']->addSubscriber(new TransitionSubscriber($container)); $container['dispatcher']->addSubscriber(new RecurringTaskSubscriber($container)); diff --git a/app/Subscriber/SubtaskTimeTrackingSubscriber.php b/app/Subscriber/SubtaskTimeTrackingSubscriber.php deleted file mode 100644 index 7e39c126e..000000000 --- a/app/Subscriber/SubtaskTimeTrackingSubscriber.php +++ /dev/null @@ -1,48 +0,0 @@ - 'updateTaskTime', - SubtaskModel::EVENT_DELETE => 'updateTaskTime', - SubtaskModel::EVENT_UPDATE => array( - array('logStartEnd', 10), - array('updateTaskTime', 0), - ) - ); - } - - public function updateTaskTime(SubtaskEvent $event) - { - if (isset($event['task_id'])) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $this->subtaskTimeTrackingModel->updateTaskTimeTracking($event['task_id']); - } - } - - public function logStartEnd(SubtaskEvent $event) - { - if (isset($event['status']) && $this->configModel->get('subtask_time_tracking') == 1) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $subtask = $this->subtaskModel->getById($event['id']); - - if (empty($subtask['user_id'])) { - return false; - } - - if ($subtask['status'] == SubtaskModel::STATUS_INPROGRESS) { - return $this->subtaskTimeTrackingModel->logStartTime($subtask['id'], $subtask['user_id']); - } else { - return $this->subtaskTimeTrackingModel->logEndTime($subtask['id'], $subtask['user_id']); - } - } - } -} diff --git a/tests/units/Job/SubtaskEventJobTest.php b/tests/units/Job/SubtaskEventJobTest.php index 66c3db05d..bdc30b519 100644 --- a/tests/units/Job/SubtaskEventJobTest.php +++ b/tests/units/Job/SubtaskEventJobTest.php @@ -41,7 +41,7 @@ class SubtaskEventJobTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'before'))); - $this->assertTrue($subtaskModel->update(array('id' => 1, 'title' => 'after'))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'title' => 'after'))); $this->assertTrue($subtaskModel->remove(1)); $called = $this->container['dispatcher']->getCalledListeners(); diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 23183d229..eed37cf30 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -5,6 +5,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\ProjectModel; +use Kanboard\Model\TaskFinderModel; class SubtaskModelTest extends Base { @@ -30,6 +31,24 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $subtask['position']); } + public function testCreationUpdateTaskTimeTracking() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 5, 'time_spent' => 5))); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(7, $task['time_estimated']); + $this->assertEquals(6, $task['time_spent']); + } + public function testModification() { $taskCreationModel = new TaskCreationModel($this->container); @@ -40,7 +59,7 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertTrue($subtaskModel->update(array('id' => 1, 'user_id' => 1, 'status' => SubtaskModel::STATUS_INPROGRESS))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'user_id' => 1, 'status' => SubtaskModel::STATUS_INPROGRESS))); $subtask = $subtaskModel->getById(1); $this->assertNotEmpty($subtask); @@ -54,6 +73,27 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $subtask['position']); } + public function testModificationUpdateTaskTimeTracking() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 2, 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'time_estimated' => 5, 'time_spent' => 5))); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(7, $task['time_estimated']); + $this->assertEquals(6, $task['time_spent']); + } + public function testRemove() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskTimeTrackingModelTest.php b/tests/units/Model/SubtaskTimeTrackingModelTest.php index cfee5b14d..8b0fe698c 100644 --- a/tests/units/Model/SubtaskTimeTrackingModelTest.php +++ b/tests/units/Model/SubtaskTimeTrackingModelTest.php @@ -2,6 +2,7 @@ require_once __DIR__.'/../Base.php'; +use Kanboard\Model\ConfigModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; @@ -10,6 +11,42 @@ use Kanboard\Model\ProjectModel; class SubtaskTimeTrackingModelTest extends Base { + public function testToggleTimer() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_TODO)); + $this->assertTrue($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_INPROGRESS)); + $this->assertTrue($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_DONE)); + } + + public function testToggleTimerWhenFeatureDisabled() + { + $configModel = new ConfigModel($this->container); + $configModel->save(array('subtask_time_tracking' => '0')); + $this->container['memoryCache']->flush(); + + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_TODO)); + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_INPROGRESS)); + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_DONE)); + } + public function testHasTimer() { $taskCreationModel = new TaskCreationModel($this->container); From df57b0f2c8b73959b6bcf237027d1c44670f961e Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 23:06:51 -0400 Subject: [PATCH 119/139] Simplify mail subject for notifications --- app/Notification/MailNotification.php | 80 ++----------------- .../Notification/MailNotificationTest.php | 4 +- 2 files changed, 10 insertions(+), 74 deletions(-) diff --git a/app/Notification/MailNotification.php b/app/Notification/MailNotification.php index 2d27179c1..a5f51b89b 100644 --- a/app/Notification/MailNotification.php +++ b/app/Notification/MailNotification.php @@ -4,10 +4,6 @@ namespace Kanboard\Notification; use Kanboard\Core\Base; use Kanboard\Core\Notification\NotificationInterface; -use Kanboard\Model\TaskModel; -use Kanboard\Model\TaskFileModel; -use Kanboard\Model\CommentModel; -use Kanboard\Model\SubtaskModel; /** * Email Notification @@ -76,76 +72,16 @@ class MailNotification extends Base implements NotificationInterface * Get the mail subject for a given template name * * @access public - * @param string $event_name Event name - * @param array $event_data Event data + * @param string $eventName Event name + * @param array $eventData Event data * @return string */ - public function getMailSubject($event_name, array $event_data) + public function getMailSubject($eventName, array $eventData) { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New attachment'), $event_data); - break; - case CommentModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New comment'), $event_data); - break; - case CommentModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Comment updated'), $event_data); - break; - case SubtaskModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New subtask'), $event_data); - break; - case SubtaskModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Subtask updated'), $event_data); - break; - case TaskModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New task'), $event_data); - break; - case TaskModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Task updated'), $event_data); - break; - case TaskModel::EVENT_CLOSE: - $subject = $this->getStandardMailSubject(e('Task closed'), $event_data); - break; - case TaskModel::EVENT_OPEN: - $subject = $this->getStandardMailSubject(e('Task opened'), $event_data); - break; - case TaskModel::EVENT_MOVE_COLUMN: - $subject = $this->getStandardMailSubject(e('Column change'), $event_data); - break; - case TaskModel::EVENT_MOVE_POSITION: - $subject = $this->getStandardMailSubject(e('Position change'), $event_data); - break; - case TaskModel::EVENT_MOVE_SWIMLANE: - $subject = $this->getStandardMailSubject(e('Swimlane change'), $event_data); - break; - case TaskModel::EVENT_ASSIGNEE_CHANGE: - $subject = $this->getStandardMailSubject(e('Assignee change'), $event_data); - break; - case TaskModel::EVENT_USER_MENTION: - case CommentModel::EVENT_USER_MENTION: - $subject = $this->getStandardMailSubject(e('Mentioned'), $event_data); - break; - case TaskModel::EVENT_OVERDUE: - $subject = e('[%s] Overdue tasks', $event_data['project_name']); - break; - default: - $subject = e('Notification'); - } - - return $subject; - } - - /** - * Get the mail subject for a given label - * - * @access private - * @param string $label Label - * @param array $data Template data - * @return string - */ - private function getStandardMailSubject($label, array $data) - { - return sprintf('[%s][%s] %s (#%d)', $data['task']['project_name'], $label, $data['task']['title'], $data['task']['id']); + return sprintf( + '[%s] %s', + $eventData['task']['project_name'], + $this->notificationModel->getTitleWithoutAuthor($eventName, $eventData) + ); } } diff --git a/tests/units/Notification/MailNotificationTest.php b/tests/units/Notification/MailNotificationTest.php index 6579d9bcd..05f1f8823 100644 --- a/tests/units/Notification/MailNotificationTest.php +++ b/tests/units/Notification/MailNotificationTest.php @@ -56,7 +56,7 @@ class MailNotificationTest extends Base 'changes' => array() ); $this->assertNotEmpty($mailNotification->getMailContent($eventName, $eventData)); - $this->assertNotEmpty($mailNotification->getMailSubject($eventName, $eventData)); + $this->assertStringStartsWith('[test] ', $mailNotification->getMailSubject($eventName, $eventData)); } } @@ -84,7 +84,7 @@ class MailNotificationTest extends Base ->with( $this->equalTo('test@localhost'), $this->equalTo('admin'), - $this->equalTo('[test][New task] test (#1)'), + $this->equalTo('[test] New task #1: test'), $this->stringContains('test') ); From 506ebf3bac302a63be7c32a03b872a9eefa689fc Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 10:08:57 -0400 Subject: [PATCH 120/139] Fixed typo in template that prevent project permissions to be duplicated --- ChangeLog | 1 + app/Template/project_creation/create.php | 2 +- app/Template/project_view/duplicate.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 1bc0eed37..6da732004 100644 --- a/ChangeLog +++ b/ChangeLog @@ -24,6 +24,7 @@ Improvements: Bug fixes: +* Fixed typo in template that prevent project permissions to be duplicated * Fixed search query with multiple assignees (nested OR conditions) * Fixed Markdown editor auto-grow on the task form (Safari) * Fixed compatibility issue with PHP 5.3 for OAuthUserProvider class diff --git a/app/Template/project_creation/create.php b/app/Template/project_creation/create.php index d00883baf..b90b15c4d 100644 --- a/app/Template/project_creation/create.php +++ b/app/Template/project_creation/create.php @@ -19,7 +19,7 @@

- form->checkbox('projectPermission', t('Permissions'), 1, true) ?> + form->checkbox('projectPermissionModel', t('Permissions'), 1, true) ?> form->checkbox('categoryModel', t('Categories'), 1, true) ?> diff --git a/app/Template/project_view/duplicate.php b/app/Template/project_view/duplicate.php index d66ff5917..561378d18 100644 --- a/app/Template/project_view/duplicate.php +++ b/app/Template/project_view/duplicate.php @@ -11,7 +11,7 @@ form->csrf() ?> - form->checkbox('projectPermission', t('Permissions'), 1, true) ?> + form->checkbox('projectPermissionModel', t('Permissions'), 1, true) ?> form->checkbox('categoryModel', t('Categories'), 1, true) ?> From 51b2193fc43a25f309a8510b64027d40bf21e12d Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 12:09:41 -0400 Subject: [PATCH 121/139] Move dashboard pagination into separate classes --- app/Controller/DashboardController.php | 72 ++-------------- app/Controller/UserListController.php | 7 +- app/Core/Base.php | 4 + app/Model/ProjectDuplicationModel.php | 2 +- app/Model/ProjectModel.php | 2 +- app/Model/TaskFinderModel.php | 86 +++++++++---------- app/Pagination/ProjectPagination.php | 35 ++++++++ app/Pagination/SubtaskPagination.php | 36 ++++++++ app/Pagination/TaskPagination.php | 35 ++++++++ app/Pagination/UserPagination.php | 32 +++++++ app/ServiceProvider/ClassProvider.php | 6 ++ app/Template/dashboard/projects.php | 4 +- app/Template/dashboard/subtasks.php | 4 +- app/Template/dashboard/tasks.php | 8 +- tests/units/Model/TaskFinderModelTest.php | 2 +- .../Pagination/ProjectPaginationTest.php | 35 ++++++++ .../Pagination/SubtaskPaginationTest.php | 36 ++++++++ tests/units/Pagination/TaskPaginationTest.php | 30 +++++++ tests/units/Pagination/UserPaginationTest.php | 27 ++++++ 19 files changed, 337 insertions(+), 126 deletions(-) create mode 100644 app/Pagination/ProjectPagination.php create mode 100644 app/Pagination/SubtaskPagination.php create mode 100644 app/Pagination/TaskPagination.php create mode 100644 app/Pagination/UserPagination.php create mode 100644 tests/units/Pagination/ProjectPaginationTest.php create mode 100644 tests/units/Pagination/SubtaskPaginationTest.php create mode 100644 tests/units/Pagination/TaskPaginationTest.php create mode 100644 tests/units/Pagination/UserPaginationTest.php diff --git a/app/Controller/DashboardController.php b/app/Controller/DashboardController.php index 44874546a..0133499fd 100644 --- a/app/Controller/DashboardController.php +++ b/app/Controller/DashboardController.php @@ -2,9 +2,6 @@ namespace Kanboard\Controller; -use Kanboard\Model\ProjectModel; -use Kanboard\Model\SubtaskModel; - /** * Dashboard Controller * @@ -13,63 +10,6 @@ use Kanboard\Model\SubtaskModel; */ class DashboardController extends BaseController { - /** - * Get project pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getProjectPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'projects', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder(ProjectModel::TABLE.'.name') - ->setQuery($this->projectModel->getQueryColumnStats($this->projectPermissionModel->getActiveProjectIds($user_id))) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects'); - } - - /** - * Get task pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getTaskPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'tasks', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder('tasks.id') - ->setQuery($this->taskFinderModel->getUserQuery($user_id)) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'tasks'); - } - - /** - * Get subtask pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getSubtaskPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'subtasks', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder('tasks.id') - ->setQuery($this->subtaskModel->getUserQuery($user_id, array(SubTaskModel::STATUS_TODO, SubtaskModel::STATUS_INPROGRESS))) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks'); - } - /** * Dashboard overview * @@ -81,9 +21,9 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/show', array( 'title' => t('Dashboard'), - 'project_paginator' => $this->getProjectPaginator($user['id'], 'show', 10), - 'task_paginator' => $this->getTaskPaginator($user['id'], 'show', 10), - 'subtask_paginator' => $this->getSubtaskPaginator($user['id'], 'show', 10), + 'project_paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'show', 10), + 'task_paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'show', 10), + 'subtask_paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'show', 10), 'user' => $user, ))); } @@ -99,7 +39,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/tasks', array( 'title' => t('My tasks'), - 'paginator' => $this->getTaskPaginator($user['id'], 'tasks', 50), + 'paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'tasks', 50), 'user' => $user, ))); } @@ -115,7 +55,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/subtasks', array( 'title' => t('My subtasks'), - 'paginator' => $this->getSubtaskPaginator($user['id'], 'subtasks', 50), + 'paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'subtasks', 50), 'user' => $user, ))); } @@ -131,7 +71,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/projects', array( 'title' => t('My projects'), - 'paginator' => $this->getProjectPaginator($user['id'], 'projects', 25), + 'paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'projects', 25), 'user' => $user, ))); } diff --git a/app/Controller/UserListController.php b/app/Controller/UserListController.php index 31fcdd44b..888583fa0 100644 --- a/app/Controller/UserListController.php +++ b/app/Controller/UserListController.php @@ -17,12 +17,7 @@ class UserListController extends BaseController */ public function show() { - $paginator = $this->paginator - ->setUrl('UserListController', 'show') - ->setMax(30) - ->setOrder('username') - ->setQuery($this->userModel->getQuery()) - ->calculate(); + $paginator = $this->userPagination->getListingPaginator(); $this->response->html($this->helper->layout->app('user_list/show', array( 'title' => t('Users').' ('.$paginator->getTotal().')', diff --git a/app/Core/Base.php b/app/Core/Base.php index 563013bda..686047854 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -122,6 +122,10 @@ use Pimple\Container; * @property \Kanboard\Model\UserNotificationFilterModel $userNotificationFilterModel * @property \Kanboard\Model\UserUnreadNotificationModel $userUnreadNotificationModel * @property \Kanboard\Model\UserMetadataModel $userMetadataModel + * @property \Kanboard\Pagination\TaskPagination $taskPagination + * @property \Kanboard\Pagination\SubtaskPagination $subtaskPagination + * @property \Kanboard\Pagination\ProjectPagination $projectPagination + * @property \Kanboard\Pagination\UserPagination $userPagination * @property \Kanboard\Validator\ActionValidator $actionValidator * @property \Kanboard\Validator\AuthValidator $authValidator * @property \Kanboard\Validator\ColumnValidator $columnValidator diff --git a/app/Model/ProjectDuplicationModel.php b/app/Model/ProjectDuplicationModel.php index 94b83c803..d32fa367e 100644 --- a/app/Model/ProjectDuplicationModel.php +++ b/app/Model/ProjectDuplicationModel.php @@ -159,7 +159,7 @@ class ProjectDuplicationModel extends Base } /** - * Make sure that the creator of the duplicated project is alsp owner + * Make sure that the creator of the duplicated project is also owner * * @access private * @param integer $dst_project_id diff --git a/app/Model/ProjectModel.php b/app/Model/ProjectModel.php index 850531c9e..d2019b723 100644 --- a/app/Model/ProjectModel.php +++ b/app/Model/ProjectModel.php @@ -318,7 +318,7 @@ class ProjectModel extends Base public function getQueryColumnStats(array $project_ids) { if (empty($project_ids)) { - return $this->db->table(ProjectModel::TABLE)->limit(0); + return $this->db->table(ProjectModel::TABLE)->eq(ProjectModel::TABLE.'.id', 0); } return $this->db diff --git a/app/Model/TaskFinderModel.php b/app/Model/TaskFinderModel.php index 7268052c0..924f339b4 100644 --- a/app/Model/TaskFinderModel.php +++ b/app/Model/TaskFinderModel.php @@ -63,19 +63,19 @@ class TaskFinderModel extends Base return $this->db ->table(TaskModel::TABLE) ->columns( - 'tasks.id', - 'tasks.title', - 'tasks.date_due', - 'tasks.date_creation', - 'tasks.project_id', - 'tasks.color_id', - 'tasks.priority', - 'tasks.time_spent', - 'tasks.time_estimated', - 'tasks.is_active', - 'tasks.creator_id', - 'projects.name AS project_name', - 'columns.title AS column_title' + TaskModel::TABLE.'.id', + TaskModel::TABLE.'.title', + TaskModel::TABLE.'.date_due', + TaskModel::TABLE.'.date_creation', + TaskModel::TABLE.'.project_id', + TaskModel::TABLE.'.color_id', + TaskModel::TABLE.'.priority', + TaskModel::TABLE.'.time_spent', + TaskModel::TABLE.'.time_estimated', + TaskModel::TABLE.'.is_active', + TaskModel::TABLE.'.creator_id', + ProjectModel::TABLE.'.name AS project_name', + ColumnModel::TABLE.'.title AS column_title' ) ->join(ProjectModel::TABLE, 'id', 'project_id') ->join(ColumnModel::TABLE, 'id', 'column_id') @@ -103,36 +103,36 @@ class TaskFinderModel extends Base '(SELECT COUNT(*) FROM '.TaskLinkModel::TABLE.' WHERE '.TaskLinkModel::TABLE.'.task_id = tasks.id) AS nb_links', '(SELECT COUNT(*) FROM '.TaskExternalLinkModel::TABLE.' WHERE '.TaskExternalLinkModel::TABLE.'.task_id = tasks.id) AS nb_external_links', '(SELECT DISTINCT 1 FROM '.TaskLinkModel::TABLE.' WHERE '.TaskLinkModel::TABLE.'.task_id = tasks.id AND '.TaskLinkModel::TABLE.'.link_id = 9) AS is_milestone', - 'tasks.id', - 'tasks.reference', - 'tasks.title', - 'tasks.description', - 'tasks.date_creation', - 'tasks.date_modification', - 'tasks.date_completed', - 'tasks.date_started', - 'tasks.date_due', - 'tasks.color_id', - 'tasks.project_id', - 'tasks.column_id', - 'tasks.swimlane_id', - 'tasks.owner_id', - 'tasks.creator_id', - 'tasks.position', - 'tasks.is_active', - 'tasks.score', - 'tasks.category_id', - 'tasks.priority', - 'tasks.date_moved', - 'tasks.recurrence_status', - 'tasks.recurrence_trigger', - 'tasks.recurrence_factor', - 'tasks.recurrence_timeframe', - 'tasks.recurrence_basedate', - 'tasks.recurrence_parent', - 'tasks.recurrence_child', - 'tasks.time_estimated', - 'tasks.time_spent', + TaskModel::TABLE.'.id', + TaskModel::TABLE.'.reference', + TaskModel::TABLE.'.title', + TaskModel::TABLE.'.description', + TaskModel::TABLE.'.date_creation', + TaskModel::TABLE.'.date_modification', + TaskModel::TABLE.'.date_completed', + TaskModel::TABLE.'.date_started', + TaskModel::TABLE.'.date_due', + TaskModel::TABLE.'.color_id', + TaskModel::TABLE.'.project_id', + TaskModel::TABLE.'.column_id', + TaskModel::TABLE.'.swimlane_id', + TaskModel::TABLE.'.owner_id', + TaskModel::TABLE.'.creator_id', + TaskModel::TABLE.'.position', + TaskModel::TABLE.'.is_active', + TaskModel::TABLE.'.score', + TaskModel::TABLE.'.category_id', + TaskModel::TABLE.'.priority', + TaskModel::TABLE.'.date_moved', + TaskModel::TABLE.'.recurrence_status', + TaskModel::TABLE.'.recurrence_trigger', + TaskModel::TABLE.'.recurrence_factor', + TaskModel::TABLE.'.recurrence_timeframe', + TaskModel::TABLE.'.recurrence_basedate', + TaskModel::TABLE.'.recurrence_parent', + TaskModel::TABLE.'.recurrence_child', + TaskModel::TABLE.'.time_estimated', + TaskModel::TABLE.'.time_spent', UserModel::TABLE.'.username AS assignee_username', UserModel::TABLE.'.name AS assignee_name', UserModel::TABLE.'.email AS assignee_email', diff --git a/app/Pagination/ProjectPagination.php b/app/Pagination/ProjectPagination.php new file mode 100644 index 000000000..8f1fa87c6 --- /dev/null +++ b/app/Pagination/ProjectPagination.php @@ -0,0 +1,35 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'projects', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(ProjectModel::TABLE.'.name') + ->setQuery($this->projectModel->getQueryColumnStats($this->projectPermissionModel->getActiveProjectIds($user_id))) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects'); + } +} diff --git a/app/Pagination/SubtaskPagination.php b/app/Pagination/SubtaskPagination.php new file mode 100644 index 000000000..f0cd61487 --- /dev/null +++ b/app/Pagination/SubtaskPagination.php @@ -0,0 +1,36 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'subtasks', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(TaskModel::TABLE.'.id') + ->setQuery($this->subtaskModel->getUserQuery($user_id, array(SubtaskModel::STATUS_TODO, SubtaskModel::STATUS_INPROGRESS))) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks'); + } +} diff --git a/app/Pagination/TaskPagination.php b/app/Pagination/TaskPagination.php new file mode 100644 index 000000000..a395ab841 --- /dev/null +++ b/app/Pagination/TaskPagination.php @@ -0,0 +1,35 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'tasks', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(TaskModel::TABLE.'.id') + ->setQuery($this->taskFinderModel->getUserQuery($user_id)) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'tasks'); + } +} diff --git a/app/Pagination/UserPagination.php b/app/Pagination/UserPagination.php new file mode 100644 index 000000000..430b7d2ff --- /dev/null +++ b/app/Pagination/UserPagination.php @@ -0,0 +1,32 @@ +paginator + ->setUrl('UserListController', 'show') + ->setMax(30) + ->setOrder(UserModel::TABLE.'.username') + ->setQuery($this->userModel->getQuery()) + ->calculate(); + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 9a71148b2..aab41c743 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -122,6 +122,12 @@ class ClassProvider implements ServiceProviderInterface 'TaskExport', 'TransitionExport', ), + 'Pagination' => array( + 'TaskPagination', + 'SubtaskPagination', + 'ProjectPagination', + 'UserPagination', + ), 'Core' => array( 'DateParser', 'Lexer', diff --git a/app/Template/dashboard/projects.php b/app/Template/dashboard/projects.php index 962e4d832..3a7f1d863 100644 --- a/app/Template/dashboard/projects.php +++ b/app/Template/dashboard/projects.php @@ -6,8 +6,8 @@ - - + + diff --git a/app/Template/dashboard/subtasks.php b/app/Template/dashboard/subtasks.php index 8e0aa3ce1..ca550e4c6 100644 --- a/app/Template/dashboard/subtasks.php +++ b/app/Template/dashboard/subtasks.php @@ -6,10 +6,10 @@
order('Id', 'id') ?>order('', 'is_private') ?>order('Id', \Kanboard\Model\ProjectModel::TABLE.'.id') ?>order('', \Kanboard\Model\ProjectModel::TABLE.'.is_private') ?> order(t('Project'), \Kanboard\Model\ProjectModel::TABLE.'.name') ?>
- + - + getCollection() as $subtask): ?> diff --git a/app/Template/dashboard/tasks.php b/app/Template/dashboard/tasks.php index b3257c335..d9cb4f9eb 100644 --- a/app/Template/dashboard/tasks.php +++ b/app/Template/dashboard/tasks.php @@ -6,12 +6,12 @@
order('Id', 'tasks.id') ?>order('Id', \Kanboard\Model\TaskModel::TABLE.'.id') ?> order(t('Project'), 'project_name') ?> order(t('Task'), 'task_name') ?>order(t('Subtask'), 'title') ?>order(t('Subtask'), \Kanboard\Model\SubtaskModel::TABLE.'.title') ?>
- + - - + + - + getCollection() as $task): ?> diff --git a/tests/units/Model/TaskFinderModelTest.php b/tests/units/Model/TaskFinderModelTest.php index 72da3b6d0..b2e2bd841 100644 --- a/tests/units/Model/TaskFinderModelTest.php +++ b/tests/units/Model/TaskFinderModelTest.php @@ -9,7 +9,7 @@ use Kanboard\Model\ProjectModel; class TaskFinderModelTest extends Base { - public function testGetTasksForDashboard() + public function testGetTasksForDashboardWithHiddenColumn() { $taskCreationModel = new TaskCreationModel($this->container); $taskFinderModel = new TaskFinderModel($this->container); diff --git a/tests/units/Pagination/ProjectPaginationTest.php b/tests/units/Pagination/ProjectPaginationTest.php new file mode 100644 index 000000000..35532d0d0 --- /dev/null +++ b/tests/units/Pagination/ProjectPaginationTest.php @@ -0,0 +1,35 @@ +container); + $projectUserRoleModel = new ProjectUserRoleModel($this->container); + $userModel = new UserModel($this->container); + $projectPagination = new ProjectPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'Project #2', 'is_private' => 1))); + $this->assertEquals(3, $projectModel->create(array('name' => 'Project #3'))); + $this->assertEquals(4, $projectModel->create(array('name' => 'Project #4', 'is_private' => 1))); + + $this->assertEquals(2, $userModel->create(array('username' => 'test'))); + $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MANAGER)); + $this->assertTrue($projectUserRoleModel->addUser(2, 2, Role::PROJECT_MANAGER)); + + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->getCollection()); + $this->assertCount(0, $projectPagination->getDashboardPaginator(3, 'projects', 5)->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.id')->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.is_private')->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.name')->getCollection()); + } +} diff --git a/tests/units/Pagination/SubtaskPaginationTest.php b/tests/units/Pagination/SubtaskPaginationTest.php new file mode 100644 index 000000000..26a51a8b7 --- /dev/null +++ b/tests/units/Pagination/SubtaskPaginationTest.php @@ -0,0 +1,36 @@ +container); + $projectModel = new ProjectModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskPagination = new SubtaskPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('task_id' => 2, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(3, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(4, $subtaskModel->create(array('task_id' => 2, 'title' => 'subtask #1'))); + $this->assertEquals(5, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1'))); + + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->getCollection()); + $this->assertCount(0, $subtaskPagination->getDashboardPaginator(2, 'subtasks', 5)->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder(TaskModel::TABLE.'.id')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder('project_name')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder('task_name')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder(SubtaskModel::TABLE.'.title')->getCollection()); + } +} diff --git a/tests/units/Pagination/TaskPaginationTest.php b/tests/units/Pagination/TaskPaginationTest.php new file mode 100644 index 000000000..027212e2f --- /dev/null +++ b/tests/units/Pagination/TaskPaginationTest.php @@ -0,0 +1,30 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskPagination = new TaskPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1))); + + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->getCollection()); + $this->assertCount(0, $taskPagination->getDashboardPaginator(2, 'tasks', 5)->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.id')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder('project_name')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.title')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.priority')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.date_due')->getCollection()); + } +} diff --git a/tests/units/Pagination/UserPaginationTest.php b/tests/units/Pagination/UserPaginationTest.php new file mode 100644 index 000000000..c475aacde --- /dev/null +++ b/tests/units/Pagination/UserPaginationTest.php @@ -0,0 +1,27 @@ +container); + $userPagination = new UserPagination($this->container); + + $this->assertEquals(2, $userModel->create(array('username' => 'test1'))); + $this->assertEquals(3, $userModel->create(array('username' => 'test2'))); + + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('id')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('username')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('name')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('email')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('role')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('twofactor_activated')->setDirection('DESC')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('is_ldap_user')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('is_active')->getCollection()); + } +} From a6d22bf2715347d4f340376efee75dc57176c8b6 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 13:00:59 -0400 Subject: [PATCH 122/139] Remove username for dashboard sidebar and change titles --- app/Controller/DashboardController.php | 18 ++++++++++-------- app/Template/dashboard/sidebar.php | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/Controller/DashboardController.php b/app/Controller/DashboardController.php index 0133499fd..f32f85527 100644 --- a/app/Controller/DashboardController.php +++ b/app/Controller/DashboardController.php @@ -20,7 +20,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/show', array( - 'title' => t('Dashboard'), + 'title' => t('Dashboard for %s', $this->helper->user->getFullname($user)), 'project_paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'show', 10), 'task_paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'show', 10), 'subtask_paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'show', 10), @@ -38,7 +38,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/tasks', array( - 'title' => t('My tasks'), + 'title' => t('Tasks overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'tasks', 50), 'user' => $user, ))); @@ -54,7 +54,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/subtasks', array( - 'title' => t('My subtasks'), + 'title' => t('Subtasks overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'subtasks', 50), 'user' => $user, ))); @@ -70,7 +70,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/projects', array( - 'title' => t('My projects'), + 'title' => t('Projects overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'projects', 25), 'user' => $user, ))); @@ -86,7 +86,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/activity', array( - 'title' => t('My activity stream'), + 'title' => t('Activity stream for %s', $this->helper->user->getFullname($user)), 'events' => $this->helper->projectActivity->getProjectsEvents($this->projectPermissionModel->getActiveProjectIds($user['id']), 100), 'user' => $user, ))); @@ -99,9 +99,11 @@ class DashboardController extends BaseController */ public function calendar() { + $user = $this->getUser(); + $this->response->html($this->helper->layout->dashboard('dashboard/calendar', array( - 'title' => t('My calendar'), - 'user' => $this->getUser(), + 'title' => t('Calendar for %s', $this->helper->user->getFullname($user)), + 'user' => $user, ))); } @@ -115,7 +117,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/notifications', array( - 'title' => t('My notifications'), + 'title' => t('Notifications for %s', $this->helper->user->getFullname($user)), 'notifications' => $this->userUnreadNotificationModel->getAll($user['id']), 'user' => $user, ))); diff --git a/app/Template/dashboard/sidebar.php b/app/Template/dashboard/sidebar.php index 86cc20f89..df4e91a53 100644 --- a/app/Template/dashboard/sidebar.php +++ b/app/Template/dashboard/sidebar.php @@ -1,5 +1,4 @@
order('Id', 'tasks.id') ?>order('Id', \Kanboard\Model\TaskModel::TABLE.'.id') ?> order(t('Project'), 'project_name') ?>order(t('Task'), 'title') ?>order(t('Priority'), 'tasks.priority') ?>order(t('Task'), \Kanboard\Model\TaskModel::TABLE.'.title') ?>order(t('Priority'), \Kanboard\Model\TaskModel::TABLE.'.priority') ?> order(t('Due date'), 'date_due') ?>order(t('Due date'), \Kanboard\Model\TaskModel::TABLE.'.date_due') ?> order(t('Column'), 'column_title') ?>
+ " + >
diff --git a/app/Template/project_header/dropdown.php b/app/Template/project_header/dropdown.php index 79a1b3896..f8901289f 100644 --- a/app/Template/project_header/dropdown.php +++ b/app/Template/project_header/dropdown.php @@ -20,14 +20,6 @@ "> -
  • - - - - -
  • user->hasProjectAccess('TaskCreationController', 'show', $project['id'])): ?> diff --git a/assets/css/app.min.css b/assets/css/app.min.css index 2ddd4a8b4..daf3fb24c 100644 --- a/assets/css/app.min.css +++ b/assets/css/app.min.css @@ -1 +1 @@ -a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file +a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file diff --git a/assets/css/print.min.css b/assets/css/print.min.css index 080626b37..d63b1216b 100644 --- a/assets/css/print.min.css +++ b/assets/css/print.min.css @@ -1 +1 @@ -a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file +a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file diff --git a/assets/css/src/board.css b/assets/css/src/board.css index 95e041089..ff3844b7a 100644 --- a/assets/css/src/board.css +++ b/assets/css/src/board.css @@ -126,7 +126,6 @@ a.board-swimlane-toggle:focus { /* board task list */ .board-task-list { - overflow: auto; min-height: 60px; } diff --git a/assets/js/app.min.js b/assets/js/app.min.js index 2a8178e45..7f3ba8939 100644 --- a/assets/js/app.min.js +++ b/assets/js/app.min.js @@ -1,2 +1,2 @@ -"use strict";var Kanboard={};Kanboard.Accordion=function(t){this.app=t},Kanboard.Accordion.prototype.listen=function(){$(document).on("click",".accordion-toggle",function(t){var e=$(this).parents(".accordion-section");t.preventDefault(),e.hasClass("accordion-collapsed")?(e.find(".accordion-content").show(),e.removeClass("accordion-collapsed")):(e.find(".accordion-content").hide(),e.addClass("accordion-collapsed"))})},Kanboard.App=function(){this.controllers={}},Kanboard.App.prototype.get=function(t){return this.controllers[t]},Kanboard.App.prototype.execute=function(){for(var t in Kanboard)if("App"!==t){var e=new Kanboard[t](this);this.controllers[t]=e,"function"==typeof e.execute&&e.execute(),"function"==typeof e.listen&&e.listen(),"function"==typeof e.focus&&e.focus(),"function"==typeof e.keyboardShortcuts&&e.keyboardShortcuts()}this.focus(),this.chosen(),this.keyboardShortcuts(),this.datePicker(),this.autoComplete(),this.tagAutoComplete()},Kanboard.App.prototype.keyboardShortcuts=function(){var t=this;Mousetrap.bindGlobal("mod+enter",function(){var e=$("form");1==e.length?e.submit():e.length>1&&("INPUT"===document.activeElement.tagName||"TEXTAREA"===document.activeElement.tagName?$(document.activeElement).parents("form").submit():t.get("Popover").isOpen()&&$("#popover-container form").submit())}),Mousetrap.bind("b",function(t){t.preventDefault(),$("#board-selector").trigger("chosen:open")}),Mousetrap.bindGlobal("esc",function(){t.get("Popover").close(),t.get("Dropdown").close()}),Mousetrap.bind("?",function(){t.get("Popover").open($("body").data("keyboard-shortcut-url"))})},Kanboard.App.prototype.focus=function(){$(document).on("focus",".auto-select",function(){$(this).select()}),$(document).on("mouseup",".auto-select",function(t){t.preventDefault()})},Kanboard.App.prototype.chosen=function(){$(".chosen-select").each(function(){var t=$(this).data("search-threshold");void 0===t&&(t=10),$(this).chosen({width:"180px",no_results_text:$(this).data("notfound"),disable_search_threshold:t})}),$(".select-auto-redirect").change(function(){var t=new RegExp($(this).data("redirect-regex"),"g");window.location=$(this).data("redirect-url").replace(t,$(this).val())})},Kanboard.App.prototype.datePicker=function(){var t=$("body"),e=t.data("js-date-format"),a=t.data("js-time-format"),o=t.data("js-lang");$.datepicker.setDefaults($.datepicker.regional[o]),$.timepicker.setDefaults($.timepicker.regional[o]),$(".form-date").datepicker({showOtherMonths:!0,selectOtherMonths:!0,dateFormat:e,constrainInput:!1}),$(".form-datetime").datetimepicker({dateFormat:e,timeFormat:a,constrainInput:!1})},Kanboard.App.prototype.tagAutoComplete=function(){$(".tag-autocomplete").select2({tags:!0})},Kanboard.App.prototype.autoComplete=function(){$(".autocomplete").each(function(){var t=$(this),e=t.data("dst-field"),a=t.data("dst-extra-field");""==$("#form-"+e).val()&&t.parent().find("button[type=submit]").attr("disabled","disabled"),t.autocomplete({source:t.data("search-url"),minLength:1,select:function(o,n){$("input[name="+e+"]").val(n.item.id),a&&$("input[name="+a+"]").val(n.item[a]),t.parent().find("button[type=submit]").removeAttr("disabled")}})})},Kanboard.App.prototype.hasId=function(t){return!!document.getElementById(t)},Kanboard.App.prototype.showLoadingIcon=function(){$("body").append(' ')},Kanboard.App.prototype.hideLoadingIcon=function(){$("#app-loading-icon").remove()},Kanboard.App.prototype.formatDuration=function(t){return t>=86400?Math.round(t/86400)+"d":t>=3600?Math.round(t/3600)+"h":t>=60?Math.round(t/60)+"m":t+"s"},Kanboard.App.prototype.isVisible=function(){var t="";return"undefined"!=typeof document.hidden?t="visibilityState":"undefined"!=typeof document.mozHidden?t="mozVisibilityState":"undefined"!=typeof document.msHidden?t="msVisibilityState":"undefined"!=typeof document.webkitHidden&&(t="webkitVisibilityState"),""!=t?"visible"==document[t]:!0},Kanboard.AvgTimeColumnChart=function(t){this.app=t},Kanboard.AvgTimeColumnChart.prototype.execute=function(){this.app.hasId("analytic-avg-time-column")&&this.show()},Kanboard.AvgTimeColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=[t.data("label")],o=[];for(var n in e)a.push(e[n].average),o.push(e[n].title);c3.generate({data:{columns:[a],type:"bar"},bar:{width:{ratio:.5}},axis:{x:{type:"category",categories:o},y:{tick:{format:this.app.formatDuration}}},legend:{show:!1}})},Kanboard.BoardCollapsedMode=function(t){this.app=t},Kanboard.BoardCollapsedMode.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("s",function(){t.toggle()})},Kanboard.BoardCollapsedMode.prototype.toggle=function(){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$('.filter-display-mode:not([style="display: none;"]) a').attr("href"),success:function(e){$(".filter-display-mode").toggle(),t.app.get("BoardDragAndDrop").refresh(e)}})},Kanboard.BoardColumnScrolling=function(t){this.app=t},Kanboard.BoardColumnScrolling.prototype.execute=function(){this.app.hasId("board")&&(this.render(),$(window).on("load",this.render),$(window).resize(this.render))},Kanboard.BoardColumnScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-height",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardColumnScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnScrolling.prototype.toggle=function(){var t=localStorage.getItem("column_scroll");void 0==t&&(t=1),localStorage.setItem("column_scroll",0==t?1:0),this.render()},Kanboard.BoardColumnScrolling.prototype.render=function(){var t=$(".board-task-list"),e=$(".board-rotation-wrapper"),a=$(".filter-max-height"),o=$(".filter-min-height");if(0==localStorage.getItem("column_scroll")){var n=80;a.show(),o.hide(),e.css("min-height",""),t.each(function(){var t=$(this).height();t>n&&(n=t)}),t.css("min-height",n),t.css("height","")}else if(a.hide(),o.show(),$(".board-swimlane").length>1)t.each(function(){$(this).height()>500?$(this).css("height",500):($(this).css("min-height",320),e.css("min-height",320))});else{var n=$(window).height()-170;t.css("height",n),e.css("min-height",n)}},Kanboard.BoardColumnView=function(t){this.app=t},Kanboard.BoardColumnView.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardColumnView.prototype.listen=function(){var t=this;$(document).on("click",".board-toggle-column-view",function(){t.toggle($(this).data("column-id"))})},Kanboard.BoardColumnView.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnView.prototype.render=function(){var t=this;$(".board-column-header").each(function(){var e=$(this).data("column-id");localStorage.getItem("hidden_column_"+e)&&t.hideColumn(e)})},Kanboard.BoardColumnView.prototype.toggle=function(t){localStorage.getItem("hidden_column_"+t)?this.showColumn(t):this.hideColumn(t)},Kanboard.BoardColumnView.prototype.hideColumn=function(t){$(".board-column-"+t+" .board-column-expanded").hide(),$(".board-column-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t+" .board-column-expanded").hide(),$(".board-column-header-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t).each(function(){$(this).removeClass("board-column-compact"),$(this).addClass("board-column-header-collapsed")}),$(".board-column-"+t).each(function(){$(this).addClass("board-column-task-collapsed")}),$(".board-column-"+t+" .board-rotation").each(function(){$(this).css("width",$(".board-column-"+t).height())}),localStorage.setItem("hidden_column_"+t,1)},Kanboard.BoardColumnView.prototype.showColumn=function(t){$(".board-column-"+t+" .board-column-expanded").show(),$(".board-column-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t+" .board-column-expanded").show(),$(".board-column-header-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t).removeClass("board-column-header-collapsed"),$(".board-column-"+t).removeClass("board-column-task-collapsed"),0==localStorage.getItem("horizontal_scroll")&&$(".board-column-header-"+t).addClass("board-column-compact"),localStorage.removeItem("hidden_column_"+t)},Kanboard.BoardHorizontalScrolling=function(t){this.app=t},Kanboard.BoardHorizontalScrolling.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardHorizontalScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-scrolling",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("c",function(){t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardHorizontalScrolling.prototype.toggle=function(){var t=localStorage.getItem("horizontal_scroll")||1;localStorage.setItem("horizontal_scroll",0==t?1:0),this.render()},Kanboard.BoardHorizontalScrolling.prototype.render=function(){0==localStorage.getItem("horizontal_scroll")?($(".filter-wide").show(),$(".filter-compact").hide(),$("#board-container").addClass("board-container-compact"),$("#board th:not(.board-column-header-collapsed)").addClass("board-column-compact")):($(".filter-wide").hide(),$(".filter-compact").show(),$("#board-container").removeClass("board-container-compact"),$("#board th").removeClass("board-column-compact"))},Kanboard.BoardPolling=function(t){this.app=t},Kanboard.BoardPolling.prototype.execute=function(){if(this.app.hasId("board")){var t=parseInt($("#board").attr("data-check-interval"));t>0&&window.setInterval(this.check.bind(this),1e3*t)}},Kanboard.BoardPolling.prototype.check=function(){if(this.app.isVisible()&&!this.app.get("BoardDragAndDrop").savingInProgress){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$("#board").data("check-url"),statusCode:{200:function(e){t.app.get("BoardDragAndDrop").refresh(e)},304:function(){t.app.hideLoadingIcon()}}})}},Kanboard.BoardTask=function(t){this.app=t},Kanboard.BoardTask.prototype.listen=function(){var t=this;$(document).on("click",".task-board-change-assignee",function(e){e.preventDefault(),e.stopPropagation(),t.app.get("Popover").open($(this).data("url"))}),$(document).on("click",".task-board",function(t){"A"!=t.target.tagName&&"IMG"!=t.target.tagName&&(window.location=$(this).data("task-url"))})},Kanboard.BoardTask.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("n",function(){t.app.get("Popover").open($("#board").data("task-creation-url"))})},Kanboard.BurndownChart=function(t){this.app=t},Kanboard.BurndownChart.prototype.execute=function(){this.app.hasId("analytic-burndown")&&this.show()},Kanboard.BurndownChart.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[[t.data("label-total")]],o=[],n=d3.time.format("%Y-%m-%d"),r=d3.time.format(t.data("date-format")),i=0;i0&&(void 0==a[0][i]&&a[0].push(0),a[0][i]+=e[i][s]),0==s&&o.push(r(n.parse(e[i][s]))));c3.generate({data:{columns:a},axis:{x:{type:"category",categories:o}}})},Kanboard.Calendar=function(t){this.app=t},Kanboard.Calendar.prototype.execute=function(){var t=$("#calendar");1==t.length&&this.show(t)},Kanboard.Calendar.prototype.show=function(t){t.fullCalendar({lang:$("body").data("js-lang"),editable:!0,eventLimit:!0,defaultView:"month",header:{left:"prev,next today",center:"title",right:"month,agendaWeek,agendaDay"},eventDrop:function(e){$.ajax({cache:!1,url:t.data("save-url"),contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({task_id:e.id,date_due:e.start.format()})})},viewRender:function(){var e=t.data("check-url"),a={start:t.fullCalendar("getView").start.format(),end:t.fullCalendar("getView").end.format()};for(var o in a)e+="&"+o+"="+a[o];$.getJSON(e,function(e){t.fullCalendar("removeEvents"),t.fullCalendar("addEventSource",e),t.fullCalendar("rerenderEvents")})}})},Kanboard.Column=function(t){this.app=t},Kanboard.Column.prototype.listen=function(){this.dragAndDrop()},Kanboard.Column.prototype.dragAndDrop=function(){var t=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")}),$(".columns-table tbody").sortable({forcePlaceholderSize:!0,handle:"td:first i",helper:function(t,e){return e.children().each(function(){$(this).width($(this).width())}),e},stop:function(e,a){var o=a.item;o.removeClass("draggable-item-selected"),t.savePosition(o.data("column-id"),o.index()+1)},start:function(t,e){e.item.addClass("draggable-item-selected")}}).disableSelection()},Kanboard.Column.prototype.savePosition=function(t,e){var a=$(".columns-table").data("save-position-url"),o=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:a,contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({column_id:t,position:e}),complete:function(){o.app.hideLoadingIcon()}})},Kanboard.CompareHoursColumnChart=function(t){this.app=t},Kanboard.CompareHoursColumnChart.prototype.execute=function(){this.app.hasId("analytic-compare-hours")&&this.show()},Kanboard.CompareHoursColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=t.data("label-open"),o=t.data("label-closed"),n=[t.data("label-spent")],r=[t.data("label-estimated")],i=[];for(var s in e)n.push(parseFloat(e[s].time_spent)),r.push(parseFloat(e[s].time_estimated)),i.push("open"==s?a:o);c3.generate({data:{columns:[n,r],type:"bar"},bar:{width:{ratio:.2}},axis:{x:{type:"category",categories:i}},legend:{show:!0}})},Kanboard.CumulativeFlowDiagram=function(t){this.app=t},Kanboard.CumulativeFlowDiagram.prototype.execute=function(){this.app.hasId("analytic-cfd")&&this.show()},Kanboard.CumulativeFlowDiagram.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[],o=[],n=[],r=d3.time.format("%Y-%m-%d"),i=d3.time.format(t.data("date-format")),s=0;s0&&o.push(e[s][d])):(a[d].push(e[s][d]),0==d&&n.push(i(r.parse(e[s][d]))));c3.generate({data:{columns:a,type:"area-spline",groups:[o]},axis:{x:{type:"category",categories:n}}})},Kanboard.Dropdown=function(t){this.app=t},Kanboard.Dropdown.prototype.listen=function(){var t=this;$(document).on("click",function(){t.close()}),$(document).on("click",".dropdown-menu",function(e){e.preventDefault(),e.stopImmediatePropagation(),t.close();var a=$(this).next("ul"),o=$(this).offset();$("body").append(jQuery("
    ",{id:"dropdown"})),a.clone().appendTo("#dropdown");var n=$("#dropdown ul");n.addClass("dropdown-submenu-open");var r=n.outerHeight(),i=n.outerWidth();o.top+r-$(window).scrollTop()<$(window).height()||$(window).scrollTop()+o.top$(window).width()?n.css("left",o.left-i+$(this).outerWidth()):n.css("left",o.left)}),$(document).on("click",".dropdown-submenu-open li",function(t){$(t.target).is("li")&&$(this).find("a:visible")[0].click()})},Kanboard.Dropdown.prototype.close=function(){$("#dropdown").remove()},Kanboard.Dropdown.prototype.onPopoverOpened=function(){this.close()},Kanboard.FileUpload=function(t){this.app=t,this.files=[],this.currentFile=0},Kanboard.FileUpload.prototype.onPopoverOpened=function(){var t=document.getElementById("file-dropzone"),e=this;t&&(t.ondragover=t.ondragenter=function(t){t.stopPropagation(),t.preventDefault()},t.ondrop=function(t){t.stopPropagation(),t.preventDefault(),e.files=t.dataTransfer.files,e.show(),$("#file-error-max-size").hide()},$(document).on("click","#file-browser",function(t){t.preventDefault(),$("#file-form-element").get(0).click()}),$(document).on("click","#file-upload-button",function(t){t.preventDefault(),e.currentFile=0,e.checkFiles()}),$("#file-form-element").change(function(){e.files=document.getElementById("file-form-element").files,e.show(),$("#file-error-max-size").hide()}))},Kanboard.FileUpload.prototype.show=function(){if($("#file-list").remove(),this.files.length>0){$("#file-upload-button").prop("disabled",!1),$("#file-dropzone-inner").hide();for(var t=jQuery("