diff --git a/airflow-core/src/airflow/ui/src/components/DagActions/DeleteDagButton.tsx b/airflow-core/src/airflow/ui/src/components/DagActions/DeleteDagButton.tsx
index 28645696b1593..afddb68436ffb 100644
--- a/airflow-core/src/airflow/ui/src/components/DagActions/DeleteDagButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagActions/DeleteDagButton.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { FiTrash2 } from "react-icons/fi";
import { useNavigate } from "react-router-dom";
@@ -33,7 +34,7 @@ type DeleteDagButtonProps = {
const DeleteDagButton = ({ dagDisplayName, dagId, withText = true }: DeleteDagButtonProps) => {
const { onClose, onOpen, open } = useDisclosure();
const navigate = useNavigate();
-
+ const { t: translate } = useTranslation("dags");
const { isPending, mutate: deleteDag } = useDeleteDag({
dagId,
onSuccessConfirm: () => {
@@ -45,11 +46,11 @@ const DeleteDagButton = ({ dagDisplayName, dagId, withText = true }: DeleteDagBu
return (
<>
}
onClick={onOpen}
- text="Delete DAG"
+ text={translate("actions.delete")}
variant="solid"
withText={withText}
/>
@@ -60,8 +61,8 @@ const DeleteDagButton = ({ dagDisplayName, dagId, withText = true }: DeleteDagBu
onDelete={() => deleteDag({ dagId })}
open={open}
resourceName={dagDisplayName}
- title="Delete DAG"
- warningText="This will remove all metadata related to the DAG, including DAG Runs and Tasks."
+ title={translate("actions.delete")}
+ warningText={translate("actions.deleteDagWarning")}
/>
>
);
diff --git a/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx b/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
index 6b98ab0df63ae..189e177200ff3 100644
--- a/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DataTable/FilterMenuButton.tsx
@@ -18,6 +18,7 @@
*/
import { IconButton } from "@chakra-ui/react";
import { flexRender, type Header, type Table } from "@tanstack/react-table";
+import { useTranslation } from "react-i18next";
import { MdFilterList } from "react-icons/md";
import { Menu } from "src/components/ui";
@@ -27,44 +28,43 @@ type Props = {
readonly table: Table;
};
-const FilterMenuButton = ({ table }: Props) => (
-
-
-
-
-
-
-
- {table.getAllLeafColumns().map((column) => {
- const text = flexRender(column.columnDef.header, {
- column,
- header: { column } as Header,
- table,
- });
+const FilterMenuButton = ({ table }: Props) => {
+ const { t: translate } = useTranslation("common");
+ const filterLabel = translate("table.filterColumns");
- return text?.toString ? (
-
- {
- column.toggleVisibility();
- }}
- >
- {text}
-
-
- ) : undefined;
- })}
-
-
-);
+ return (
+
+
+
+
+
+
+
+ {table.getAllLeafColumns().map((column) => {
+ const text = flexRender(column.columnDef.header, {
+ column,
+ header: { column } as Header,
+ table,
+ });
+
+ return text?.toString ? (
+
+ {
+ column.toggleVisibility();
+ }}
+ >
+ {text}
+
+
+ ) : undefined;
+ })}
+
+
+ );
+};
export default FilterMenuButton;
diff --git a/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx b/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
index 6d8ac6b56fc91..b77262838bf54 100644
--- a/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
@@ -18,6 +18,7 @@
*/
import { Text, Heading, HStack } from "@chakra-ui/react";
import React from "react";
+import { useTranslation } from "react-i18next";
import { FiTrash2 } from "react-icons/fi";
import { Button, Dialog } from "src/components/ui";
@@ -34,7 +35,7 @@ type DeleteDialogProps = {
};
const DeleteDialog: React.FC = ({
- deleteButtonText = "Delete",
+ deleteButtonText,
isDeleting,
onClose,
onDelete,
@@ -42,33 +43,36 @@ const DeleteDialog: React.FC = ({
resourceName,
title,
warningText,
-}) => (
-
-
-
- {title}
-
-
-
-
- Are you sure you want to delete {resourceName}? This action cannot be undone.
-
-
- {warningText}
-
-
-
-
-
-
-
-
-
-
-);
+}) => {
+ const { t: translate } = useTranslation("common");
+
+ return (
+
+
+
+ {title}
+
+
+
+ {translate("modal.delete.confirmation", { resourceName })}
+
+ {warningText}
+
+
+
+
+
+
+
+
+
+
+ );
+};
export default DeleteDialog;
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx b/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
index 264b82c770580..0b0faaf6b4bb5 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.test.tsx
@@ -31,7 +31,7 @@ describe("Test SearchBar", () => {
const input = screen.getByTestId("search-dags");
- expect(screen.getByText("Advanced Search")).toBeDefined();
+ expect(screen.getByText("advancedSearch")).toBeDefined();
expect(screen.queryByTestId("clear-search")).toBeNull();
fireEvent.change(input, { target: { value: "search" } });
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
index 5578aae541523..b70a5655b7127 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
@@ -19,6 +19,7 @@
import { Button, Input, Kbd, type ButtonProps } from "@chakra-ui/react";
import { useState, useRef, type ChangeEvent } from "react";
import { useHotkeys } from "react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
import { FiSearch } from "react-icons/fi";
import { useDebouncedCallback } from "use-debounce";
@@ -51,7 +52,7 @@ export const SearchBar = ({
const searchRef = useRef(null);
const [value, setValue] = useState(defaultValue);
const metaKey = getMetaKey();
-
+ const { t: translate } = useTranslation(["dags"]);
const onSearchChange = (event: ChangeEvent) => {
setValue(event.target.value);
handleSearchChange(event.target.value);
@@ -73,7 +74,7 @@ export const SearchBar = ({
<>
{Boolean(value) ? (
{
@@ -85,7 +86,7 @@ export const SearchBar = ({
) : undefined}
{Boolean(hideAdvanced) ? undefined : (
)}
{!hotkeyDisabled && {metaKey}+K}
diff --git a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
index ce79b04dd2ae3..07f87d11cb747 100644
--- a/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TriggerDag/TriggerDAGButton.tsx
@@ -18,6 +18,7 @@
*/
import { Box } from "@chakra-ui/react";
import { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { FiPlay } from "react-icons/fi";
import type { DAGResponse, DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
@@ -32,15 +33,16 @@ type Props = {
const TriggerDAGButton: React.FC = ({ dag, withText = true }) => {
const { onClose, onOpen, open } = useDisclosure();
+ const { t: translate } = useTranslation("dags");
return (
}
onClick={onOpen}
- text="Trigger"
+ text={translate("actions.trigger")}
variant="solid"
withText={withText}
/>
diff --git a/airflow-core/src/airflow/ui/src/constants/sortParams.ts b/airflow-core/src/airflow/ui/src/constants/sortParams.ts
index 3ffc36844e82e..39864ba7f9589 100644
--- a/airflow-core/src/airflow/ui/src/constants/sortParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/sortParams.ts
@@ -17,22 +17,42 @@
* under the License.
*/
import { createListCollection } from "@chakra-ui/react/collection";
+import type { TFunction } from "i18next";
-export const dagSortOptions = createListCollection({
- items: [
- { label: "Sort by Display Name (A-Z)", value: "dag_display_name" },
- { label: "Sort by Display Name (Z-A)", value: "-dag_display_name" },
- { label: "Sort by Next DAG Run (Earliest-Latest)", value: "next_dagrun" },
- { label: "Sort by Next DAG Run (Latest-Earliest)", value: "-next_dagrun" },
- { label: "Sort by Latest Run State (A-Z)", value: "last_run_state" },
- { label: "Sort by Latest Run State (Z-A)", value: "-last_run_state" },
- {
- label: "Sort by Latest Run Start Date (Earliest-Latest)",
- value: "last_run_start_date",
- },
- {
- label: "Sort by Latest Run Start Date (Latest-Earliest)",
- value: "-last_run_start_date",
- },
- ],
-});
+export const createDagSortOptions = (translate: TFunction) =>
+ createListCollection({
+ items: [
+ {
+ label: translate("sort.displayName.asc"),
+ value: "dag_display_name",
+ },
+ {
+ label: translate("sort.displayName.desc"),
+ value: "-dag_display_name",
+ },
+ {
+ label: translate("sort.nextDagRun.asc"),
+ value: "next_dagrun",
+ },
+ {
+ label: translate("sort.nextDagRun.desc"),
+ value: "-next_dagrun",
+ },
+ {
+ label: translate("sort.lastRunState.asc"),
+ value: "last_run_state",
+ },
+ {
+ label: translate("sort.lastRunState.desc"),
+ value: "-last_run_state",
+ },
+ {
+ label: translate("sort.lastRunStartDate.asc"),
+ value: "last_run_start_date",
+ },
+ {
+ label: translate("sort.lastRunStartDate.desc"),
+ value: "-last_run_start_date",
+ },
+ ],
+ });
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 944ca987bd5e0..045216407a9bf 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -23,6 +23,7 @@ import { initReactI18next } from "react-i18next";
import deCommon from "./locales/de/common.json";
import deDashboard from "./locales/de/dashboard.json";
import enCommon from "./locales/en/common.json";
+import enDags from "./locales/en/dags.json";
import enDashboard from "./locales/en/dashboard.json";
import koCommon from "./locales/ko/common.json";
import koDashboard from "./locales/ko/dashboard.json";
@@ -43,7 +44,7 @@ export const supportedLanguages = [
] as const;
export const defaultLanguage = "en";
-export const namespaces = ["common", "dashboard"] as const;
+export const namespaces = ["common", "dashboard", "dags"] as const;
const resources = {
de: {
@@ -52,6 +53,7 @@ const resources = {
},
en: {
common: enCommon,
+ dags: enDags,
dashboard: enDashboard,
},
ko: {
diff --git a/airflow-core/src/airflow/ui/src/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/src/i18n/locales/en/common.json
index b1a3a0d0c42a0..59e26eae483ff 100644
--- a/airflow-core/src/airflow/ui/src/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/src/i18n/locales/en/common.json
@@ -13,6 +13,8 @@
"auditLog": "Audit Log",
"xcoms": "XComs"
},
+ "dag_one": "Dag",
+ "dag_other": "Dags",
"dagRun_one": "Dag Run",
"dagRun_other": "Dag Runs",
"defaultToGraphView": "Default to graph view",
@@ -26,7 +28,11 @@
"logoutConfirmation": "You are about to logout from the application.",
"modal": {
"cancel": "Cancel",
- "confirm": "Confirm"
+ "confirm": "Confirm",
+ "delete": {
+ "button": "Delete",
+ "confirmation": "Are you sure you want to delete {{resourceName}}? This action cannot be undone."
+ }
},
"nav": {
"admin": "Admin",
@@ -72,6 +78,21 @@
},
"switchToDarkMode": "Switch to Dark Mode",
"switchToLightMode": "Switch to Light Mode",
+ "table": {
+ "filterByTag": "Filter Dags by tag",
+ "filterColumns": "Filter table columns",
+ "filters": {
+ "filter_one": "filter",
+ "filter_other": "filters",
+ "reset": "Reset"
+ },
+ "noTagsFound": "No tags found",
+ "tagMode": {
+ "all": "All",
+ "any": "Any"
+ },
+ "tagPlaceholder": "Filter by tag"
+ },
"taskInstance_one": "Task Instance",
"taskInstance_other": "Task Instances",
"timeRange": {
diff --git a/airflow-core/src/airflow/ui/src/i18n/locales/en/dags.json b/airflow-core/src/airflow/ui/src/i18n/locales/en/dags.json
new file mode 100644
index 0000000000000..cb6db5cc3cec7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/locales/en/dags.json
@@ -0,0 +1,55 @@
+{
+ "actions": {
+ "delete": "Delete Dag",
+ "deleteDagWarning": "This will remove all metadata related to the DAG, including DAG Runs and Tasks.",
+ "trigger": "Trigger",
+ "triggerDag": "Trigger Dag"
+ },
+ "assetSchedule": "{{count}} of {{total}} assets updated",
+ "filters": {
+ "paused": {
+ "active": "Active",
+ "all": "All",
+ "paused": "Paused"
+ }
+ },
+ "list": {
+ "advancedSearch": "Advanced Search",
+ "clearSearch": "Clear search",
+ "columns": {
+ "dagId": "Dag ID",
+ "lastDagRun": "Last Dag Run",
+ "nextDagRun": "Next Dag Run",
+ "schedule": "Schedule",
+ "tags": "Tags"
+ },
+ "ownerLink": "Owner link for {{owner}}",
+ "runs": {
+ "duration": "Duration",
+ "endDate": "End Date",
+ "runAfter": "Run After",
+ "startDate": "Start Date",
+ "state": "State"
+ },
+ "searchPlaceholder": "Search Dags"
+ },
+ "sort": {
+ "displayName": {
+ "asc": "Sort by Display Name (A-Z)",
+ "desc": "Sort by Display Name (Z-A)"
+ },
+ "lastRunStartDate": {
+ "asc": "Sort by Latest Run Start Date (Earliest-Latest)",
+ "desc": "Sort by Latest Run Start Date (Latest-Earliest)"
+ },
+ "lastRunState": {
+ "asc": "Sort by Latest Run State (A-Z)",
+ "desc": "Sort by Latest Run State (Z-A)"
+ },
+ "nextDagRun": {
+ "asc": "Sort by Next Dag Run (Earliest-Latest)",
+ "desc": "Sort by Next Dag Run (Latest-Earliest)"
+ },
+ "placeholder": "Sort by"
+ }
+}
diff --git a/airflow-core/src/airflow/ui/src/layouts/DagsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/DagsLayout.tsx
index 331fd77e68f32..3838179e0d4bc 100644
--- a/airflow-core/src/airflow/ui/src/layouts/DagsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/DagsLayout.tsx
@@ -18,18 +18,23 @@
*/
import { Box } from "@chakra-ui/react";
import type { PropsWithChildren } from "react";
+import { useTranslation } from "react-i18next";
import { NavTabs } from "./Details/NavTabs";
-const tabs = [
- { label: "Dags", value: "/dags" },
- { label: "Runs", value: "/dag_runs" },
- { label: "Task Instances", value: "/task_instances" },
-];
+export const DagsLayout = ({ children }: PropsWithChildren) => {
+ const { t: translate } = useTranslation("common");
-export const DagsLayout = ({ children }: PropsWithChildren) => (
-
-
- {children}
-
-);
+ const tabs = [
+ { label: translate("nav.dags"), value: "/dags" },
+ { label: translate("dagRun_other"), value: "/dag_runs" },
+ { label: translate("taskInstance_other"), value: "/task_instances" },
+ ];
+
+ return (
+
+
+ {children}
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
index c566a5532ace6..39a51c4be0ca1 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
@@ -18,6 +18,7 @@
*/
import { HStack, Text, Link } from "@chakra-ui/react";
import dayjs from "dayjs";
+import { useTranslation } from "react-i18next";
import { FiDatabase } from "react-icons/fi";
import { Link as RouterLink } from "react-router-dom";
@@ -32,6 +33,7 @@ type Props = {
};
export const AssetSchedule = ({ dag }: Props) => {
+ const { t: translate } = useTranslation("dags");
const { data: nextRun, isLoading } = useAssetServiceNextRunAssets({ dagId: dag.dag_id });
const nextRunEvents = (nextRun?.events ?? []) as Array;
@@ -72,8 +74,7 @@ export const AssetSchedule = ({ dag }: Props) => {
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 26b2655c26aa2..46a9b362a72b1 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { Box, Flex, HStack, SimpleGrid, Link, Spinner } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
@@ -37,6 +38,7 @@ type Props = {
};
export const DagCard = ({ dag }: Props) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
const [latestRun] = dag.latest_dag_runs;
const refetchInterval = useAutoRefresh({ isPaused: dag.is_paused });
@@ -64,10 +66,10 @@ export const DagCard = ({ dag }: Props) => {
-
+
-
+
{latestRun ? (
@@ -83,7 +85,7 @@ export const DagCard = ({ dag }: Props) => {
) : undefined}
-
+
{Boolean(dag.next_dagrun_run_after) ? (
| null;
readonly owners?: Array;
}) => {
+ const { t: translate } = useTranslation("dags");
const items = owners.map((owner) => {
const link = ownerLinks?.[owner];
const hasOwnerLink = link !== undefined;
return hasOwnerLink ? (
{
- const [searchParams, setSearchParams] = useSearchParams();
-
- const showPaused = searchParams.get(PAUSED_PARAM);
- const state = searchParams.get(LAST_DAG_RUN_STATE_PARAM);
- const selectedTags = searchParams.getAll(TAGS_PARAM);
- const tagFilterMode = searchParams.get(TAGS_MATCH_MODE_PARAM) ?? "any";
- const isAll = state === null;
- const isRunning = state === "running";
- const isFailed = state === "failed";
- const isSuccess = state === "success";
-
- const { data } = useDagServiceGetDagTags({
- orderBy: "name",
- });
-
- const hidePausedDagsByDefault = Boolean(useConfig("hide_paused_dags_by_default"));
- const defaultShowPaused = hidePausedDagsByDefault ? "false" : "all";
-
- const { setTableURLState, tableURLState } = useTableURLState();
- const { pagination, sorting } = tableURLState;
-
- const handlePausedChange = useCallback(
- ({ value }: SelectValueChangeDetails) => {
- const [val] = value;
-
- if (val === undefined || val === "all") {
- searchParams.delete(PAUSED_PARAM);
- } else {
- searchParams.set(PAUSED_PARAM, val);
- }
- setTableURLState({
- pagination: { ...pagination, pageIndex: 0 },
- sorting,
- });
- setSearchParams(searchParams);
- },
- [pagination, searchParams, setSearchParams, setTableURLState, sorting],
- );
-
- const handleStateChange: React.MouseEventHandler = useCallback(
- ({ currentTarget: { value } }) => {
- if (value === "all") {
- searchParams.delete(LAST_DAG_RUN_STATE_PARAM);
- } else {
- searchParams.set(LAST_DAG_RUN_STATE_PARAM, value);
- }
- setTableURLState({
- pagination: { ...pagination, pageIndex: 0 },
- sorting,
- });
- setSearchParams(searchParams);
- },
- [pagination, searchParams, setSearchParams, setTableURLState, sorting],
- );
- const handleSelectTagsChange = useCallback(
- (
- tags: MultiValue<{
- label: string;
- value: string;
- }>,
- ) => {
- searchParams.delete(TAGS_PARAM);
- tags.forEach(({ value }) => {
- searchParams.append(TAGS_PARAM, value);
- });
- if (tags.length < 2) {
- searchParams.delete(TAGS_MATCH_MODE_PARAM);
- }
- setSearchParams(searchParams);
- },
- [searchParams, setSearchParams],
- );
-
- const onClearFilters = () => {
- searchParams.delete(PAUSED_PARAM);
- searchParams.delete(LAST_DAG_RUN_STATE_PARAM);
- searchParams.delete(TAGS_PARAM);
- searchParams.delete(TAGS_MATCH_MODE_PARAM);
-
- setSearchParams(searchParams);
- };
-
- let filterCount = 0;
-
- if (state !== null) {
- filterCount += 1;
- }
- if (showPaused !== null) {
- filterCount += 1;
- }
- if (selectedTags.length > 0) {
- filterCount += 1;
- }
-
- const handleTagModeChange = useCallback(
- ({ checked }: { checked: boolean }) => {
- const mode = checked ? "all" : "any";
-
- searchParams.set(TAGS_MATCH_MODE_PARAM, mode);
- setSearchParams(searchParams);
- },
- [searchParams, setSearchParams],
- );
-
- return (
-
-
-
-
- All
-
-
-
- Failed
-
-
-
- Running
-
-
-
- Success
-
-
-
-
-
-
-
- {enabledOptions.items.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- ({
- ...provided,
- color: "gray.fg",
- }),
- container: (provided) => ({
- ...provided,
- minWidth: 64,
- }),
- control: (provided) => ({
- ...provided,
- colorPalette: "blue",
- }),
- menu: (provided) => ({
- ...provided,
- zIndex: 2,
- }),
- }}
- isClearable
- isMulti
- noOptionsMessage={() => "No tags found"}
- onChange={handleSelectTagsChange}
- options={
- data?.tags.map((tag) => ({
- label: tag,
- value: tag,
- })) ?? []
- }
- placeholder="Filter by tag"
- value={selectedTags.map((tag) => ({
- label: tag,
- value: tag,
- }))}
- />
-
- {selectedTags.length >= 2 && (
-
-
- Any
-
-
-
- All
-
-
- )}
-
-
- {filterCount > 0 && (
-
- )}
-
-
- );
-};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
new file mode 100644
index 0000000000000..d36e2b2f8ef46
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
@@ -0,0 +1,181 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, HStack } from "@chakra-ui/react";
+import type { MultiValue } from "chakra-react-select";
+import { useCallback } from "react";
+import { useSearchParams } from "react-router-dom";
+
+import { useDagServiceGetDagTags } from "openapi/queries";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams";
+import { useConfig } from "src/queries/useConfig";
+
+import { PausedFilter } from "./PausedFilter";
+import { ResetButton } from "./ResetButton";
+import { StateFilters } from "./StateFilters";
+import { TagFilter } from "./TagFilter";
+
+const {
+ LAST_DAG_RUN_STATE: LAST_DAG_RUN_STATE_PARAM,
+ PAUSED: PAUSED_PARAM,
+ TAGS: TAGS_PARAM,
+ TAGS_MATCH_MODE: TAGS_MATCH_MODE_PARAM,
+}: SearchParamsKeysType = SearchParamsKeys;
+
+const getFilterCount = (state: string | null, showPaused: string | null, selectedTags: Array) => {
+ let count = 0;
+
+ if (state !== null) {
+ count += 1;
+ }
+ if (showPaused !== null) {
+ count += 1;
+ }
+ if (selectedTags.length > 0) {
+ count += 1;
+ }
+
+ return count;
+};
+
+export const DagsFilters = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const showPaused = searchParams.get(PAUSED_PARAM);
+ const state = searchParams.get(LAST_DAG_RUN_STATE_PARAM);
+ const selectedTags = searchParams.getAll(TAGS_PARAM);
+ const tagFilterMode = searchParams.get(TAGS_MATCH_MODE_PARAM) ?? "any";
+ const isAll = state === null;
+ const isRunning = state === "running";
+ const isFailed = state === "failed";
+ const isSuccess = state === "success";
+
+ const { data } = useDagServiceGetDagTags({
+ orderBy: "name",
+ });
+
+ const hidePausedDagsByDefault = Boolean(useConfig("hide_paused_dags_by_default"));
+ const defaultShowPaused = hidePausedDagsByDefault ? "false" : "all";
+
+ const { setTableURLState, tableURLState } = useTableURLState();
+ const { pagination, sorting } = tableURLState;
+
+ const handlePausedChange = useCallback(
+ ({ value }: { value: Array }) => {
+ const [val] = value;
+
+ if (val === undefined || val === "all") {
+ searchParams.delete(PAUSED_PARAM);
+ } else {
+ searchParams.set(PAUSED_PARAM, val);
+ }
+ setTableURLState({
+ pagination: { ...pagination, pageIndex: 0 },
+ sorting,
+ });
+ setSearchParams(searchParams);
+ },
+ [pagination, searchParams, setSearchParams, setTableURLState, sorting],
+ );
+
+ const handleStateChange: React.MouseEventHandler = useCallback(
+ ({ currentTarget: { value } }) => {
+ if (value === "all") {
+ searchParams.delete(LAST_DAG_RUN_STATE_PARAM);
+ } else {
+ searchParams.set(LAST_DAG_RUN_STATE_PARAM, value);
+ }
+ setTableURLState({
+ pagination: { ...pagination, pageIndex: 0 },
+ sorting,
+ });
+ setSearchParams(searchParams);
+ },
+ [pagination, searchParams, setSearchParams, setTableURLState, sorting],
+ );
+
+ const handleSelectTagsChange = useCallback(
+ (
+ tags: MultiValue<{
+ label: string;
+ value: string;
+ }>,
+ ) => {
+ searchParams.delete(TAGS_PARAM);
+ tags.forEach(({ value }) => {
+ searchParams.append(TAGS_PARAM, value);
+ });
+ if (tags.length < 2) {
+ searchParams.delete(TAGS_MATCH_MODE_PARAM);
+ }
+ setSearchParams(searchParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const onClearFilters = () => {
+ searchParams.delete(PAUSED_PARAM);
+ searchParams.delete(LAST_DAG_RUN_STATE_PARAM);
+ searchParams.delete(TAGS_PARAM);
+ searchParams.delete(TAGS_MATCH_MODE_PARAM);
+
+ setSearchParams(searchParams);
+ };
+
+ const handleTagModeChange = useCallback(
+ ({ checked }: { checked: boolean }) => {
+ const mode = checked ? "all" : "any";
+
+ searchParams.set(TAGS_MATCH_MODE_PARAM, mode);
+ setSearchParams(searchParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const filterCount = getFilterCount(state, showPaused, selectedTags);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx
new file mode 100644
index 0000000000000..61f467630b444
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/PausedFilter.tsx
@@ -0,0 +1,59 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createListCollection, type SelectValueChangeDetails } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { Select } from "src/components/ui";
+
+type Props = {
+ readonly defaultShowPaused: string;
+ readonly onPausedChange: (details: SelectValueChangeDetails) => void;
+ readonly showPaused: string | null;
+};
+
+export const PausedFilter = ({ defaultShowPaused, onPausedChange, showPaused }: Props) => {
+ const { t: translate } = useTranslation("dags");
+
+ const enabledOptions = createListCollection({
+ items: [
+ { label: translate("filters.paused.all"), value: "all" },
+ { label: translate("filters.paused.active"), value: "false" },
+ { label: translate("filters.paused.paused"), value: "true" },
+ ],
+ });
+
+ return (
+
+
+
+
+
+ {enabledOptions.items.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/ResetButton.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/ResetButton.tsx
new file mode 100644
index 0000000000000..32905fc9fcff5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/ResetButton.tsx
@@ -0,0 +1,41 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Button } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { LuX } from "react-icons/lu";
+
+type Props = {
+ readonly filterCount: number;
+ readonly onClearFilters: () => void;
+};
+
+export const ResetButton = ({ filterCount, onClearFilters }: Props) => {
+ const { t: translate } = useTranslation("common");
+
+ if (filterCount === 0) {
+ return undefined;
+ }
+
+ return (
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx
new file mode 100644
index 0000000000000..0ec17d809b49f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/StateFilters.tsx
@@ -0,0 +1,70 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { HStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { QuickFilterButton } from "src/components/QuickFilterButton";
+import { StateBadge } from "src/components/StateBadge";
+
+type Props = {
+ readonly isAll: boolean;
+ readonly isFailed: boolean;
+ readonly isRunning: boolean;
+ readonly isSuccess: boolean;
+ readonly onStateChange: React.MouseEventHandler;
+};
+
+export const StateFilters = ({ isAll, isFailed, isRunning, isSuccess, onStateChange }: Props) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
+
+ return (
+
+
+ {translate("filters.paused.all")}
+
+
+
+ {translate("common:states.failed")}
+
+
+
+ {translate("common:states.running")}
+
+
+
+ {translate("common:states.success")}
+
+
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx
new file mode 100644
index 0000000000000..57fa2b1089de1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/TagFilter.tsx
@@ -0,0 +1,101 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Field, HStack, Text } from "@chakra-ui/react";
+import { Select as ReactSelect, type MultiValue } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
+
+import { Switch } from "src/components/ui";
+
+type Props = {
+ readonly onSelectTagsChange: (tags: MultiValue<{ label: string; value: string }>) => void;
+ readonly onTagModeChange: ({ checked }: { checked: boolean }) => void;
+ readonly selectedTags: Array;
+ readonly tagFilterMode: string;
+ readonly tags: Array;
+};
+
+export const TagFilter = ({
+ onSelectTagsChange,
+ onTagModeChange,
+ selectedTags,
+ tagFilterMode,
+ tags,
+}: Props) => {
+ const { t: translate } = useTranslation("common");
+
+ return (
+ <>
+
+ ({
+ ...provided,
+ color: "gray.fg",
+ }),
+ container: (provided) => ({
+ ...provided,
+ minWidth: 64,
+ }),
+ control: (provided) => ({
+ ...provided,
+ colorPalette: "blue",
+ }),
+ menu: (provided) => ({
+ ...provided,
+ zIndex: 2,
+ }),
+ }}
+ isClearable
+ isMulti
+ noOptionsMessage={() => translate("table.noTagsFound")}
+ onChange={onSelectTagsChange}
+ options={tags.map((tag) => ({
+ label: tag,
+ value: tag,
+ }))}
+ placeholder={translate("table.tagPlaceholder")}
+ value={selectedTags.map((tag) => ({
+ label: tag,
+ value: tag,
+ }))}
+ />
+
+ {selectedTags.length >= 2 && (
+
+
+ {translate("table.tagMode.any")}
+
+
+
+ {translate("table.tagMode.all")}
+
+
+ )}
+ >
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/index.ts b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/index.ts
new file mode 100644
index 0000000000000..6c88a1f6a1d73
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { DagsFilters } from "./DagsFilters";
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index 60b45fbaca99b..e06098d4dce01 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -26,7 +26,8 @@ import {
Box,
} from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
-import { useCallback, useState } from "react";
+import { useCallback, useState, useMemo } from "react";
+import { useTranslation } from "react-i18next";
import { Link as RouterLink, useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
@@ -45,7 +46,6 @@ import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searc
import { DagsLayout } from "src/layouts/DagsLayout";
import { useConfig } from "src/queries/useConfig";
import { useDags } from "src/queries/useDags";
-import { pluralize } from "src/utils";
import { DAGImportErrors } from "../Dashboard/Stats/DAGImportErrors";
import { DagCard } from "./DagCard";
@@ -54,7 +54,9 @@ import { DagsFilters } from "./DagsFilters";
import { Schedule } from "./Schedule";
import { SortSelect } from "./SortSelect";
-const columns: Array> = [
+const createColumns = (
+ translate: (key: string, options?: Record) => string,
+): Array> => [
{
accessorKey: "is_paused",
cell: ({ row: { original } }) => (
@@ -77,13 +79,13 @@ const columns: Array> = [
{original.dag_display_name}
),
- header: "Dag",
+ header: () => translate("list.columns.dagId"),
},
{
accessorKey: "timetable_description",
cell: ({ row: { original } }) => ,
enableSorting: false,
- header: () => "Schedule",
+ header: () => translate("list.columns.schedule"),
},
{
accessorKey: "next_dagrun",
@@ -94,7 +96,7 @@ const columns: Array> = [
runAfter={original.next_dagrun_run_after as string}
/>
) : undefined,
- header: "Next Dag Run",
+ header: () => translate("list.columns.nextDagRun"),
},
{
accessorKey: "last_run_start_date",
@@ -112,7 +114,7 @@ const columns: Array> = [
) : undefined,
- header: "Last Dag Run",
+ header: () => translate("list.columns.lastDagRun"),
},
{
accessorKey: "tags",
@@ -122,22 +124,18 @@ const columns: Array> = [
},
}) => ,
enableSorting: false,
- header: () => "Tags",
+ header: () => translate("list.columns.tags"),
},
{
accessorKey: "trigger",
- cell: ({ row }) => ,
+ cell: ({ row: { original } }) => ,
enableSorting: false,
header: "",
},
{
accessorKey: "delete",
- cell: ({ row }) => (
-
+ cell: ({ row: { original } }) => (
+
),
enableSorting: false,
header: "",
@@ -162,6 +160,7 @@ const cardDef: CardDef = {
const DAGS_LIST_DISPLAY = "dags_list_display";
export const DagsList = () => {
+ const { t: translate } = useTranslation(["dags", "common"]);
const [searchParams, setSearchParams] = useSearchParams();
const [display, setDisplay] = useLocalStorage<"card" | "table">(DAGS_LIST_DISPLAY, "card");
const dagRunsLimit = display === "card" ? 14 : 1;
@@ -185,6 +184,8 @@ export const DagsList = () => {
const [sort] = sorting;
const orderBy = sort ? `${sort.desc ? "-" : ""}${sort.id}` : "dag_display_name";
+ const columns = useMemo(() => createColumns(translate), [translate]);
+
const handleSearchChange = (value: string) => {
if (value) {
searchParams.set(NAME_PATTERN_PARAM, value);
@@ -241,13 +242,13 @@ export const DagsList = () => {
buttonProps={{ disabled: true }}
defaultValue={dagDisplayNamePattern ?? ""}
onChange={handleSearchChange}
- placeHolder="Search Dags"
+ placeHolder={translate("list.searchPlaceholder")}
/>
- {pluralize("Dag", data.total_entries)}
+ {`${data.total_entries} ${data.total_entries === 1 ? translate("common:dag_one") : translate("common:dag_other")}`}
@@ -266,7 +267,7 @@ export const DagsList = () => {
errorMessage={}
initialState={tableURLState}
isLoading={isLoading}
- modelName="Dag"
+ modelName={translate("common:dag_one")}
onStateChange={setTableURLState}
skeletonCount={display === "card" ? 5 : undefined}
total={data.total_entries}
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
index 5e7def016502b..af6c5c1a14655 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/RecentRuns.tsx
@@ -19,6 +19,7 @@
import { Flex, Box, Text } from "@chakra-ui/react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
+import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
@@ -35,6 +36,8 @@ export const RecentRuns = ({
}: {
readonly latestRuns: DAGWithLatestDagRunsResponse["latest_dag_runs"];
}) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
+
if (!latestRuns.length) {
return undefined;
}
@@ -55,21 +58,25 @@ export const RecentRuns = ({
- State: {run.state}
- Run After:
+ {translate("list.runs.state")}: {translate(`common:states.${run.state}`)}
+
+
+ {translate("list.runs.runAfter")}:
{run.start_date === null ? undefined : (
- Start Date:
+ {translate("list.runs.startDate")}:
)}
{run.end_date === null ? undefined : (
- End Date:
+ {translate("list.runs.endDate")}:
)}
- Duration: {getDuration(run.start_date, run.end_date)}
+
+ {translate("list.runs.duration")}: {getDuration(run.start_date, run.end_date)}
+
}
key={run.dag_run_id}
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
index 9f071cfb43735..0810fa743fb73 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/Schedule.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { Text } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { FiCalendar } from "react-icons/fi";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
@@ -28,9 +29,11 @@ type Props = {
readonly dag: DAGWithLatestDagRunsResponse;
};
-export const Schedule = ({ dag }: Props) =>
- Boolean(dag.timetable_summary) ? (
- Boolean(dag.asset_expression) || dag.timetable_summary === "Asset" ? (
+export const Schedule = ({ dag }: Props) => {
+ const { t: translate } = useTranslation("dags");
+
+ return Boolean(dag.timetable_summary) ? (
+ Boolean(dag.asset_expression) || dag.timetable_summary === translate("schedule.asset") ? (
) : (
@@ -40,3 +43,4 @@ export const Schedule = ({ dag }: Props) =>
)
) : undefined;
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/SortSelect.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/SortSelect.tsx
index 256bc862de3b3..e0e66b9033f96 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/SortSelect.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/SortSelect.tsx
@@ -17,32 +17,38 @@
* under the License.
*/
import type { SelectValueChangeDetails } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
import { Select } from "src/components/ui";
-import { dagSortOptions } from "src/constants/sortParams";
+import { createDagSortOptions } from "src/constants/sortParams";
type Props = {
readonly handleSortChange: ({ value }: SelectValueChangeDetails>) => void;
readonly orderBy?: string;
};
-export const SortSelect = ({ handleSortChange, orderBy }: Props) => (
-
-
-
-
-
- {dagSortOptions.items.map((option) => (
-
- {option.label}
-
- ))}
-
-
-);
+export const SortSelect = ({ handleSortChange, orderBy }: Props) => {
+ const { t: translate } = useTranslation("dags");
+ const dagSortOptions = createDagSortOptions(translate);
+
+ return (
+
+
+
+
+
+ {dagSortOptions.items.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};